class WizardsController < ApplicationController
STEPS = %w[account profile confirm].freeze
before_action :set_step
def show
@signup = Signup.new(session[:signup] || {})
render :show
end
def update
merge_step_params
@signup = Signup.new(session[:signup])
unless @signup.valid_step?(@step)
return render :show, status: :unprocessable_entity
end
if @step == STEPS.last
user = @signup.complete!
session.delete(:signup)
redirect_to dashboard_path(user), notice: "Welcome aboard!"
else
redirect_to wizard_path(next_step)
end
end
private
def set_step
@step = params[:step].presence_in(STEPS) || STEPS.first
@step_index = STEPS.index(@step)
end
def next_step
STEPS[@step_index + 1]
end
def merge_step_params
session[:signup] ||= {}
session[:signup].merge!(signup_params.to_h)
end
def signup_params
params.require(:signup).permit(:email, :password, :full_name, :company, :accepted_terms)
end
end
<div class="wizard">
<%= turbo_frame_tag "wizard" do %>
<ol class="wizard__steps">
<% WizardsController::STEPS.each_with_index do |name, i| %>
<li class="<%= "is-active" if i == @step_index %> <%= "is-done" if i < @step_index %>">
<%= name.titleize %>
</li>
<% end %>
</ol>
<section class="wizard__body">
<%= render partial: @step, locals: { signup: @signup } %>
</section>
<% if @step_index.positive? %>
<%= link_to "← Back",
wizard_path(WizardsController::STEPS[@step_index - 1]),
data: { turbo_frame: "wizard" },
class: "wizard__back" %>
<% end %>
<% end %>
</div>
<%= form_with model: signup,
url: wizard_path("account"),
method: :patch,
data: { turbo_frame: "wizard" } do |f| %>
<% if signup.errors[:email].any? || signup.errors[:password].any? %>
<div class="form-errors" role="alert">
Please fix the highlighted fields below.
</div>
<% end %>
<div class="field">
<%= f.label :email %>
<%= f.email_field :email, autofocus: true %>
<% signup.errors[:email].each do |msg| %>
<span class="field__error"><%= msg %></span>
<% end %>
</div>
<div class="field">
<%= f.label :password %>
<%= f.password_field :password, autocomplete: "new-password" %>
<% signup.errors[:password].each do |msg| %>
<span class="field__error"><%= msg %></span>
<% end %>
</div>
<%= f.submit "Continue", class: "btn btn--primary" %>
<% end %>
class Signup
include ActiveModel::Model
attr_accessor :email, :password, :full_name, :company, :accepted_terms
STEP_FIELDS = {
"account" => %i[email password],
"profile" => %i[full_name],
"confirm" => %i[accepted_terms]
}.freeze
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 8 }, allow_blank: false
validates :full_name, presence: true
validates :accepted_terms, acceptance: true
def valid_step?(step)
valid?
relevant = STEP_FIELDS.fetch(step, [])
(errors.attribute_names & relevant).empty?
end
def complete!
User.create!(
email: email,
password: password,
full_name: full_name,
company: company.presence
)
end
end
This snippet shows a multi-step signup wizard where each step swaps in place inside a single <turbo-frame> rather than doing a full page load. The pattern keeps the browser on one URL while the frame's inner content changes, giving a native SPA-like feel with almost no client-side code. It solves the classic wizard problem of partial state: the user's answers accumulate across steps without persisting an incomplete record to the database until the final submit.
In WizardsController, the wizard is modeled as an ordered list of STEPS, with the current step resolved from the route parameter and validated against that list. State is held in session[:signup] — a plain hash that survives between requests — so each step's params are merged in via merge_step_params before advancing. show renders the step; update validates only the fields relevant to the current step through Signup.new(signup_params).valid_step?(step), and either re-renders the same frame with errors or redirects to next_step. The final step calls complete!, which is where the real User record is created and the session scratch space is cleared.
The crucial detail is turbo_frame_request?: when the request targets the frame, responses stay scoped to that frame; otherwise a direct visit still renders a full page, so deep links and refreshes work. Redirects between steps are followed by Turbo and re-scoped to the frame automatically because the target frame id matches.
In show.html.erb, everything lives inside turbo_frame_tag "wizard". The step partial is rendered dynamically by name, and a small progress indicator reflects @step_index. Because the frame wraps both the form and the navigation, submitting or clicking "back" only repaints the frame.
In _account.html.erb, the form posts to wizard_path(step) and its data: { turbo_frame: "wizard" } ensures the response replaces the wizard frame even though the form lives inside it. Error rendering is inline per-field. The trade-off of this approach is that abandoned wizards leave transient session data rather than orphaned rows, and state size must stay small since sessions are cookie- or store-bound. It is the right tool when the flow is short, mostly linear, and the final entity should only exist once every step is valid.
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.