ruby erb 129 lines · 4 tabs

Multi-step wizard navigation with Turbo Frames

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

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

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.

Multi-step wizard navigation with Turbo Frames — share card
Link copied