ruby erb 83 lines · 3 tabs

Turbo-friendly 422 responses for invalid forms

Shared by codesnips Jan 2026
3 tabs
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
3 files · ruby, erb Explain with highlit

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

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
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

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
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

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<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)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Turbo-friendly 422 responses for invalid forms — share card
Link copied