class WizardsController < ApplicationController
before_action :load_draft
def step_1
render_step(1)
end
def update_step_1
if update_draft(step_1_params)
redirect_to wizard_step_2_path
else
render_step(1, status: :unprocessable_entity)
end
end
def step_2
render_step(2)
end
def update_step_2
if update_draft(step_2_params)
redirect_to wizard_step_3_path
else
render_step(2, status: :unprocessable_entity)
end
end
def step_3
render_step(3)
end
def create
if @draft.update(step_3_params.merge(completed: true))
session.delete(:wizard_draft_id)
redirect_to @draft, notice: 'Application submitted successfully!'
else
render_step(3, status: :unprocessable_entity)
end
end
private
def load_draft
@draft = WizardDraft.find_or_create_by(id: session[:wizard_draft_id], user: current_user)
session[:wizard_draft_id] = @draft.id
end
def update_draft(params)
@draft.update(params)
end
def render_step(step, status: :ok)
render "wizards/step_#{step}", locals: { draft: @draft }, status: status
end
def step_1_params
params.require(:wizard_draft).permit(:name, :email, :phone)
end
def step_2_params
params.require(:wizard_draft).permit(:address, :city, :state, :zip)
end
def step_3_params
params.require(:wizard_draft).permit(:preferences, :notes)
end
end
<div class="wizard-container">
<div class="wizard-progress mb-8">
<div class="step active">1. Personal Info</div>
<div class="step">2. Address</div>
<div class="step">3. Preferences</div>
</div>
<%= turbo_frame_tag "wizard_form" do %>
<%= form_with model: @draft,
url: wizard_update_step_1_path,
method: :patch do |f| %>
<h2 class="text-2xl font-bold mb-6">Personal Information</h2>
<div class="space-y-4">
<div class="form-group">
<%= f.label :name %>
<%= f.text_field :name, class: "form-input", required: true %>
<%= render "shared/field_errors", object: @draft, field: :name %>
</div>
<div class="form-group">
<%= f.label :email %>
<%= f.email_field :email, class: "form-input", required: true %>
<%= render "shared/field_errors", object: @draft, field: :email %>
</div>
<div class="form-group">
<%= f.label :phone %>
<%= f.tel_field :phone, class: "form-input" %>
<%= render "shared/field_errors", object: @draft, field: :phone %>
</div>
</div>
<div class="flex justify-between mt-8">
<div></div>
<%= f.submit "Continue", class: "btn btn-primary" %>
</div>
<% end %>
<% end %>
</div>
Rails.application.routes.draw do
get 'wizard/step-1', to: 'wizards#step_1', as: :wizard_step_1
patch 'wizard/step-1', to: 'wizards#update_step_1', as: :wizard_update_step_1
get 'wizard/step-2', to: 'wizards#step_2', as: :wizard_step_2
patch 'wizard/step-2', to: 'wizards#update_step_2', as: :wizard_update_step_2
get 'wizard/step-3', to: 'wizards#step_3', as: :wizard_step_3
post 'wizard', to: 'wizards#create', as: :wizard_create
end
Multi-step forms traditionally require complex JavaScript state management, but Hotwire makes them simple. Each step is a separate controller action that renders a Turbo Frame containing the current step's fields. Navigation between steps updates only the frame, preserving completed data in the session or a draft record. I validate each step server-side before allowing progress to the next. Back/forward buttons work naturally with Turbo's history management. This approach keeps form logic in Rails—validation, defaults, conditional fields—while delivering a SPA-like experience. The form submits normally on the final step, and server-side validation is the single source of truth throughout.