LaunchKit

A Rails multistep form without a wizard gem

September 19, 2026

Two columns carry the whole state of a multistep flow here. onboarding_step is a string naming where the user is. onboarded_at is a timestamp, null until they finish. There is no workflow table, no state machine, and no gem.

The controller does two things

def show
  @form = @step.form_for(Current.user)
end

def update
  @form = @step.form_for(Current.user, step_params)

  if @form.save
    advance
  else
    render :show, status: :unprocessable_entity
  end
end

Render the step's form, or hand it the params and see whether it saved. The controller never learns what a step collects, which is what makes adding one a change to a form class and a partial rather than a change here.

status: :unprocessable_entity on the failure branch is doing real work. Turbo ignores a 200 response to a form submission that is not a redirect, so a validation error rendered with the default status produces a page that visibly does nothing: the user clicks Continue, nothing moves, and no error appears. The 422 is what makes Turbo replace the frame and show the messages.

Where the position is written

def advance
  next_step = Onboarding::Flow.after(@step)
  return complete unless next_step

  Current.user.update!(onboarding_step: next_step.key)
  redirect_to onboarding_path(next_step)
end

The order matters and it is easy to get backwards. The stored position moves after the form saved, and it names the step the user is about to see rather than the one they just finished.

Writing it before the save would park somebody on a step they never completed the moment a validation failed. Storing the completed step instead of the next one pushes the arithmetic into resume, which then has to look up the step, find the one after it, and handle the case where the completed step was the last. Naming the destination keeps the resume logic to a single lookup.

complete runs when there is no next step: it stamps onboarded_at, clears onboarding_step, sends the welcome notification, enrolls the user in the onboarding email sequence, and redirects to the dashboard. Clearing the step key rather than leaving it on the last one is what makes onboarded_at the single source of truth about whether the flow is done.

Resuming, including onto a step you deleted

def resume(key)
  find(key) || first
end

Two words of code and they cover the case that otherwise produces a support ticket. find returns nil for any key that does not match a current step, and the || sends that user to the beginning instead of to an exception.

That matters because the step list is editable. Remove workspace from the flow on a Tuesday, and every user whose onboarding_step column reads "workspace" is holding a key that resolves to nothing. Without the fallback they get a 500 on the page they are required to visit, and they cannot reach anything else in the product either, because the gate keeps sending them back.

Running that case is the way to believe it. Set onboarding_step to a string no form class matches, ask Onboarding::Flow.resume for it, and it hands back the first step rather than raising:

user.update!(onboarding_step: "a_step_that_was_deleted")
Onboarding::Flow.resume(user.onboarding_step)
# => #<Onboarding::Step key=:welcome_to_launchkit>

Two lanes under the heading Flow dot resume of user dot onboarding underscore step. The top lane, labelled key still in the list, goes from a white box reading quote profile quote, subtitled the column's value, to a white box reading find arrow match, subtitled searches the active list, to a green box reading the profile step, subtitled resumed where they left off. The bottom lane, labelled step since deleted, goes from a white box reading quote workspace quote, subtitled nothing answers to it, to a yellow box reading find arrow nil, subtitled then the double pipe takes over, to a green box reading the first step, subtitled not a 500, not a migration. Caption: two words of code, and deleting a step stops being a data problem.

The navigator holds no state

def after(step)  = steps[index(step) + 1]
def last?(step)  = step.key == steps.last&.key
def position(step) = index(step) + 1
def total = steps.size

Onboarding::Flow is class methods over a list. It does not know the user, does not read the database, and does not decide anything about validation. Asked for the step after this one, it answers with a list lookup.

position and total exist for the progress indicator, and they are derived rather than stored. A "step 2 of 5" rendered from a counter on the user record is a number that goes wrong the first time the flow changes length. Computed from the list, it cannot.

The &. in last? covers an empty flow. A registry that finds no active steps is a broken configuration rather than a crash, and every method here degrades to nil or zero instead of raising inside a view.

One scan per request

def steps
  Current.onboarding_steps ||= Onboarding::Registry.active_steps
end

Onboarding::Registry.active_steps globs a directory and parses a YAML file. A page rendering a progress bar, a form and a partial can ask for the step list a dozen times, and without a memo that is a dozen directory scans.

Current is an ActiveSupport::CurrentAttributes subclass, which is what makes this memo the right shape rather than a constant. The Rails executor resets it at the request boundary, so the scan happens once per request and an edit to config/onboarding_steps.yml is picked up on the very next one. No reboot, no cache to invalidate, and no stale flow surviving in a worker that happened to serve the request before the deploy.

Caching it in a class-level variable instead would buy a little more speed and cost you the property that matters: composing the flow in the admin would then require a restart to take effect.

What this page does not cover

Branching. Every user walks the same list in the same order, and a flow that shows step three only to people on a paid plan is a different design: the registry returns a list, and a list cannot ask who is reading it.

Nor does it cover going backwards. The flow has no back button, because each step's form writes to the user as it goes rather than accumulating answers in the session, so there is nothing to undo and no draft state to reconcile. Making the steps revisitable is possible and it changes what advance means.

More on Onboarding in Rails

← All Onboarding in Rails articles