ruby erb 125 lines · 3 tabs

Multi-Step Wizard Form in Rails with a Form Object and Session-Backed State

Shared by codesnips Sep 2026
3 tabs
class OnboardingForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  STEPS = %i[account profile preferences].freeze

  attribute :current_step, :string, default: "account"

  attribute :email, :string
  attribute :password, :string
  attribute :full_name, :string
  attribute :company, :string
  attribute :newsletter, :boolean, default: false
  attribute :plan, :string, default: "free"

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :account
  validates :password, presence: true, length: { minimum: 8 }, on: :account
  validates :full_name, presence: true, on: :profile
  validates :plan, inclusion: { in: %w[free pro team] }, on: :preferences

  def step_sym
    current_step.to_sym
  end

  def valid_step?
    valid?(step_sym)
  end

  def last_step?
    step_sym == STEPS.last
  end

  def next_step
    idx = STEPS.index(step_sym)
    STEPS[idx + 1] || STEPS.last
  end

  def previous_step
    idx = STEPS.index(step_sym)
    STEPS[[idx - 1, 0].max]
  end

  def persist!
    ApplicationRecord.transaction do
      user = User.create!(email: email, password: password, newsletter: newsletter)
      user.create_profile!(full_name: full_name, company: company, plan: plan)
      user
    end
  end

  def to_session
    attributes.except("current_step")
  end
end
3 files · ruby, erb Explain with highlit

A multi-step wizard splits one large form into several screens while still producing a single persisted record at the end. The tricky part is holding partially-valid state between requests without polluting the database with half-finished rows. This snippet keeps the in-progress data in the session and validates only the fields relevant to the current step, so each screen enforces its own rules and the final commit runs the full validation set.

In OnboardingForm, an ActiveModel::Model plays the role of a plain-Ruby form object. It declares every attribute the wizard collects across all steps via ActiveModel::Attributes, which gives type coercion and dirty tracking without touching the database. The STEPS constant defines the ordered screens, and current_step drives conditional validation: each validates call is gated on on: context or if: so that, for example, email rules only fire during the :account step. next_step and previous_step walk the ordered list, and last_step? tells the controller when to persist. persist! wraps the real ActiveRecord writes in a transaction so the account and profile are created atomically.

The valid_step? method calls valid?(current_step) so validations run against the matching context. This is the core trade-off of the pattern: correctness of the final object depends on remembering to re-run full validation at commit time, since session-stored attributes are never guaranteed complete.

In OnboardingsController, the form is rehydrated on every request from session[:onboarding], a plain hash of previously submitted values. show renders the view for the requested step; update merges the new params, validates just that step, and either advances or re-renders with errors. Storing only a hash — not the form object — keeps the session small and avoids serializing Ruby objects, which is safer across deploys. When last_step? is reached and validation passes, persist! runs and the session slice is cleared.

The onboarding.html.erb view is step-agnostic: it renders a partial named after @form.current_step and posts back to the same update action. A hidden step field ensures the server knows which screen submitted. This pattern suits signups, checkout, and complex intake flows where a single monolithic form would overwhelm users and where premature persistence is undesirable.


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 Form in Rails with a Form Object and Session-Backed State — share card
Link copied