class ArticlesController < ApplicationController
before_action :set_article, only: %i[edit update]
def new
@article = Article.new
end
def create
@article = Article.new(article_params)
if @article.save
redirect_to @article, notice: "Article created."
else
render :new, status: :unprocessable_entity
end
end
def update
if @article.update(article_params)
redirect_to @article, notice: "Article updated."
else
render :edit, status: :unprocessable_entity
end
end
private
def set_article
@article = Article.find(params[:id])
end
def article_params
params.require(:article).permit(:title, :body, :published)
end
end
<%= form_with model: @article, class: "article-form" do |form| %>
<% if @article.errors.any? %>
<div class="form-errors" role="alert">
<h2><%= pluralize(@article.errors.count, "error") %> prevented saving:</h2>
<ul>
<% @article.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<%= field_wrapper(@article, :title) do %>
<%= form.label :title %>
<%= form.text_field :title %>
<% end %>
<%= field_wrapper(@article, :body) do %>
<%= form.label :body %>
<%= form.text_area :body, rows: 8 %>
<% end %>
<div class="field checkbox">
<%= form.check_box :published %>
<%= form.label :published %>
</div>
<%= form.submit class: "btn btn-primary" %>
<% end %>
module FormHelpers
def field_wrapper(object, attribute, &block)
errors = object.errors[attribute]
css = ["field"]
css << "is-invalid" if errors.any?
content_tag(:div, class: css.join(" ")) do
safe_join([
capture(&block),
field_error(errors)
].compact)
end
end
def field_error(errors)
return if errors.empty?
content_tag(:span, errors.to_sentence, class: "field-error")
end
end
Turbo Drive intercepts standard form submissions and expects the server to speak a specific dialect: on a validation failure the response must carry a non-2xx status (conventionally 422 Unprocessable Entity) so Turbo re-renders the returned HTML in place instead of treating it as a successful navigation. A plain render :new with the default 200 OK leaves Turbo confused — it discards the response and the form appears to do nothing. These files show the correct wiring across a controller, its form partial, and a small helper.
In ArticlesController, both create and update follow the same shape: attempt to persist, and on success issue a redirect (which Turbo happily follows), but on failure call render with status: :unprocessable_entity. That symbol maps to 422, the signal Turbo watches for. Returning the same view that hosts the form is essential, because the invalid @article instance still carries its errors, and that is what gets re-rendered with messages attached.
The _form partial uses form_with model: @article, which Rails renders with data-turbo intact so the submission stays within the Turbo lifecycle. The partial iterates over @article.errors to surface messages inline, and each field reflects its own error state via a CSS class. Because the whole page (or a turbo_frame) is swapped, the user sees their entered values preserved alongside the messages — no manual state juggling.
The FormHelpers module centralizes the error-decoration logic in field_wrapper, keeping the partial declarative. It checks object.errors[attribute] and conditionally applies an is-invalid class, avoiding repeated conditionals across many inputs.
The key trade-off is that this approach re-renders server-side rather than validating on the client, which keeps a single source of truth for validation rules at the cost of a round trip. A common pitfall is forgetting the status code entirely, or wrapping the form in a turbo_frame_tag whose id does not match on the response — Turbo will then drop the frame with a console warning. When richer partial updates are needed, the same controller can instead respond with turbo_stream, but the 422 render remains the simplest, most robust default for invalid forms.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.