class SignupsController < ApplicationController
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
redirect_to dashboard_path, notice: "Welcome aboard!"
else
respond_to do |format|
format.turbo_stream { render :create, status: :unprocessable_entity }
format.html { render :new, status: :unprocessable_entity }
end
end
end
def validate_field
attribute = params.require(:attribute).to_sym
@user = User.new(user_params)
@user.valid? # populates errors for every attribute
@attribute = attribute
render :validate_field
end
private
def user_params
params.fetch(:user, {}).permit(:email, :username, :password)
end
end
<%# locals: user:, attribute:, type: "text" %>
<%= turbo_frame_tag "field_#{attribute}" do %>
<div class="form-group">
<%= label_tag "user_#{attribute}", attribute.to_s.titleize %>
<%= tag.input(
type: type,
name: "user[#{attribute}]",
id: "user_#{attribute}",
value: user.public_send(attribute),
class: ["form-control", ("is-invalid" if user.errors[attribute].any?)],
data: { action: "blur->signup-form#validate", attribute: attribute }
) %>
<% if user.errors[attribute].any? %>
<span class="invalid-feedback d-block">
<%= user.errors[attribute].to_sentence %>
</span>
<% end %>
</div>
<% end %>
<%= turbo_stream.replace "field_#{@attribute}" do %>
<%= render "signup_field",
user: @user,
attribute: @attribute,
type: @attribute == :password ? "password" : "text" %>
<% end %>
import { Controller } from "@hotwired/stimulus"
import { Turbo } from "@hotwired/turbo-rails"
export default class extends Controller {
static targets = ["form"]
validate(event) {
const input = event.target
const attribute = input.dataset.attribute
if (!attribute) return
const body = new FormData()
body.append("attribute", attribute)
body.append(`user[${attribute}]`, input.value)
fetch(this.data.get("url"), {
method: "POST",
headers: {
"Accept": "text/vnd.turbo-stream.html",
"X-CSRF-Token": document.querySelector("meta[name=csrf-token]").content
},
body
})
.then((response) => response.text())
.then((html) => Turbo.renderStreamMessage(html))
}
}
This snippet shows how to give a Rails form immediate, per-field validation feedback without a full page reload or a heavy front-end framework, by leaning on Turbo Streams. The idea is that the server remains the single source of truth for validation: it runs the real ActiveRecord validations and streams back only the fragments that need to change. That avoids duplicating validation logic in JavaScript and keeps the error messages consistent with what a final submit would produce.
The SignupsController handles two moments. In create, a valid record redirects as usual, but an invalid one responds with unprocessable_entity and renders a Turbo Stream template instead of a full HTML page. The separate validate_field action powers live, on-blur checking: it assigns only the submitted attributes, calls valid? to populate errors, and re-renders just the affected field wrapper. Because valid? runs the whole validation suite, the action reads a single attribute param to decide which fragment to stream, keeping unrelated fields untouched.
The signup_field partial is the reusable unit of the UI. Each field lives inside a turbo_frame_tag (or a stably-ided div) so a stream can target it with turbo_stream.replace. It renders the input, an is-invalid class when user.errors[attribute] is present, and the message text. Rendering the same partial from the form and from the stream response guarantees the invalid and valid states look identical.
The validate_field turbo_stream template maps errors to DOM updates: it replaces the field wrapper for the validated attribute so the browser swaps in the new markup in place. On create failure a similar template can replace every field plus a summary.
The signup_form Stimulus controller supplies the progressive-enhancement glue. On blur it submits the field to validate_field via fetch with a Turbo Stream Accept header, and Turbo applies the returned stream automatically. The trade-off is an extra request per field interaction, so debouncing and validating on blur rather than every keystroke keeps traffic sane. The pattern shines when validation rules are non-trivial or server-derived (uniqueness, external checks) and duplicating them client-side would be error-prone.
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.