# Build a LiveView wizard that remembers every step

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.

```elixir
# 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](https://elixirdrops.net/d/kg3pPtVH) — no schema, no database until the very end:

```elixir
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:

```elixir
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:

```heex
<.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:

```elixir
@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:**

- [Phoenix.Component.to_form/2](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#to_form/2)
- [Ecto.Changeset.apply_action/2](https://hexdocs.pm/ecto/Ecto.Changeset.html#apply_action/2)
- [Schemaless changesets guide](https://hexdocs.pm/ecto/Ecto.Changeset.html#module-schemaless-changesets)
- [Schemaless changesets validate forms without the database](https://elixirdrops.net/d/kg3pPtVH) — the changeset technique this wizard builds on


---

Created by: almirsarajcic
Date: July 02, 2026
URL: https://elixirdrops.net/d/xL7nH81N
