We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Build a LiveView wizard that remembers every step
almirsarajcic
Multi-step forms usually break in one of two ways: they lose everything when the user clicks “Back”, or they defer all validation to the final submit. Keep the current step and the data collected so far in socket assigns, validate one step at a time, and back-and-forth navigation becomes free.
# The two handlers that ARE the wizard
def handle_event("next", %{"wizard" => params}, socket) do
case RegistrationWizard.apply_step(socket.assigns.step, params) do
{:ok, valid} ->
{:noreply,
socket
|> assign(step: socket.assigns.step + 1, data: Map.merge(socket.assigns.data, valid))
|> assign_form()}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset, as: :wizard))}
end
end
# Back never validates, so a half-typed step still lets you return
def handle_event("back", _params, socket) do
{:noreply,
socket
|> assign(step: socket.assigns.step - 1)
|> assign_form()}
end
# Rebuild the current step's form from everything entered so far
defp assign_form(socket) do
changeset = RegistrationWizard.changeset(socket.assigns.step, socket.assigns.data)
assign(socket, form: to_form(changeset, as: :wizard))
end
Why this works:
step+datain assigns — the socket is your wizard state.dataaccumulates each step’s validated values viaMap.merge/2.apply_stepgates advancing — only a valid step incrementsstep. Invalid params re-render the same step with errors.backskips validation — decrement and rebuild the form. Becauseassign_formreads from accumulateddata, every field the user already filled reappears.
Each step is a schemaless changeset — no schema, no database until the very end:
defmodule MyApp.RegistrationWizard do
import Ecto.Changeset
@fields %{
1 => %{name: :string, email: :string},
2 => %{company: :string, role: :string},
3 => %{password: :string, password_confirmation: :string}
}
# A schemaless changeset for one step — no schema, no database
def changeset(step, params) do
types = @fields[step]
{%{}, types}
|> cast(params, Map.keys(types))
|> validate(step)
end
# Advance guard: {:ok, data} when the step is valid, else {:error, changeset}
def apply_step(step, params) do
step
|> changeset(params)
|> apply_action(:insert)
end
defp validate(changeset, 1) do
changeset
|> validate_required([:name, :email])
|> validate_format(:email, ~r/@/)
end
defp validate(changeset, 2), do: validate_required(changeset, [:company, :role])
defp validate(changeset, 3) do
changeset
|> validate_required([:password, :password_confirmation])
|> validate_length(:password, min: 12)
|> validate_confirmation(:password)
end
end
Pull the per-step inputs into a function component with one clause per step. A single attr block validates every call, and pattern-matching on %{step: n} keeps each step’s markup isolated:
attr :form, :any, required: true
attr :step, :integer, required: true
def fields(%{step: 1} = assigns) do
~H"""
<.input field={@form[:name]} label="Name" />
<.input field={@form[:email]} label="Email" type="email" />
"""
end
def fields(%{step: 2} = assigns) do
~H"""
<.input field={@form[:company]} label="Company" />
<.input field={@form[:role]} label="Role" />
"""
end
def fields(%{step: 3} = assigns) do
~H"""
<.input field={@form[:password]} label="Password" type="password" />
<.input field={@form[:password_confirmation]} label="Confirm password" type="password" />
"""
end
Now one <.form> renders the whole wizard — <.fields> swaps the inputs by step, and the same submit button means “Next” until the last step:
<.form
for={@form}
phx-change="validate"
phx-submit={if @step == @last_step, do: "submit", else: "next"}
>
<.fields form={@form} step={@step} />
<.button :if={@step > 1} type="button" phx-click="back">Back</.button>
<.button>{if @step == @last_step, do: "Create account", else: "Next"}</.button>
</.form>
The Back button is type="button" so it triggers phx-click without submitting the form. Adding a step is now a one-clause change to fields/1 and one entry in @fields — the form, the handlers, and the button labels all keep working untouched. The remaining glue — mount, live validation, and the final submit that runs the real insert once every step has passed:
@last_step 3
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(step: 1, data: %{}, last_step: @last_step)
|> assign_form()}
end
# Live per-keystroke errors for the current step only
def handle_event("validate", %{"wizard" => params}, socket) do
changeset =
socket.assigns.step
|> RegistrationWizard.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset, as: :wizard))}
end
# Fires only on the final step (see phx-submit in the template)
def handle_event("submit", %{"wizard" => params}, socket) do
case RegistrationWizard.apply_step(@last_step, params) do
{:ok, valid} ->
all_params = Map.merge(socket.assigns.data, valid)
case Accounts.register_user(all_params) do
{:ok, _user} ->
{:noreply,
socket
|> put_flash(:info, "Account created!")
|> push_navigate(to: ~p"/dashboard")}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset, as: :wizard))}
end
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset, as: :wizard))}
end
end
Gotcha — schemaless changesets need as:: to_form/2 derives the form name (the "wizard" param key) from the changeset’s underlying struct. A schemaless changeset’s data is a bare map, so there’s nothing to derive from — to_form(changeset) raises cannot generate name for changeset where the data is not backed by a struct. Always pass as: :wizard.
Pro tips:
- Socket state survives LiveView re-renders but not a full page reload. For refresh-proof wizards, persist
datato the session or a draft row and hydrate it inmount. - Keep the field-to-step map (
@fields) in one place so the changeset, the template, and the step count never drift. - Rendering all steps’ inputs and hiding the inactive ones with CSS is tempting, but the clause-per-step
<.fields>keeps the others out of the DOM entirely — the browser won’t submit or autofill fields the current step doesn’t own.
Links:
- Phoenix.Component.to_form/2
- Ecto.Changeset.apply_action/2
- Schemaless changesets guide
- Schemaless changesets validate forms without the database — the changeset technique this wizard builds on
copied to clipboard