Build a LiveView wizard that remembers every step

almirsarajcic

almirsarajcic

1 hour ago

0 comments

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 + data in assigns — the socket is your wizard state. data accumulates each step’s validated values via Map.merge/2.
  • apply_step gates advancing — only a valid step increments step. Invalid params re-render the same step with errors.
  • back skips validation — decrement and rebuild the form. Because assign_form reads from accumulated data, 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 data to the session or a draft row and hydrate it in mount.
  • 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:

Comments (0)

Sign in with GitHub to join the discussion