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
class OnboardingsController < ApplicationController
before_action :load_form
def show
render :onboarding
end
def update
@form.assign_attributes(form_params)
session[:onboarding] = @form.to_session
unless @form.valid_step?
return render :onboarding, status: :unprocessable_entity
end
if @form.last_step?
user = @form.persist!
session.delete(:onboarding)
sign_in(user)
redirect_to dashboard_path, notice: "Welcome aboard!"
else
@form.current_step = @form.next_step.to_s
redirect_to onboarding_path(step: @form.current_step)
end
end
private
def load_form
stored = session[:onboarding] || {}
@form = OnboardingForm.new(stored)
requested = params[:step].presence&.to_sym
@form.current_step = (requested if OnboardingForm::STEPS.include?(requested))&.to_s ||
@form.current_step
end
def form_params
params.require(:onboarding_form).permit(
:email, :password, :full_name, :company, :newsletter, :plan, :current_step
)
end
end
<section class="wizard">
<ol class="wizard-steps">
<% OnboardingForm::STEPS.each do |step| %>
<li class="<%= "is-active" if step == @form.step_sym %>">
<%= step.to_s.titleize %>
</li>
<% end %>
</ol>
<%= form_with model: @form, scope: :onboarding_form,
url: onboarding_path, method: :patch, local: true do |f| %>
<% if @form.errors.any? %>
<div class="errors">
<%= @form.errors.full_messages.to_sentence %>
</div>
<% end %>
<%= f.hidden_field :current_step, value: @form.current_step %>
<%= render "onboardings/#{@form.step_sym}", f: f %>
<div class="wizard-actions">
<% unless @form.step_sym == OnboardingForm::STEPS.first %>
<%= link_to "Back", onboarding_path(step: @form.previous_step) %>
<% end %>
<%= f.submit @form.last_step? ? "Finish" : "Continue" %>
</div>
<% end %>
</section>
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
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.