Rendering forms with Phoenix

View Source

Zoi works seamlessly with Phoenix forms through the Phoenix.HTML.FormData protocol. This guide walks through building a complete LiveView form step by step.

1. Define Your Schema

First, define your validation schema inline using Zoi.Form.prepare/1:

defmodule MyAppWeb.UserLive.FormComponent do
  use MyAppWeb, :live_view

  @user_schema Zoi.map(%{
    name: Zoi.string() |> Zoi.min(3),
    email: Zoi.email(),
    age: Zoi.integer() |> Zoi.min(18) |> Zoi.optional()
  }) |> Zoi.Form.prepare()
end

Zoi.Form.prepare/1 enables automatic coercion so form strings convert to the right types (integers, booleans, etc.).

2. Parse and Render

Parse params with Zoi.Form.parse/2 and convert the context to a Phoenix form:

def mount(_params, _session, socket) do
  params = %{}  # Start with empty form
  ctx = Zoi.Form.parse(@user_schema, params)

  {:ok, assign(socket, to_form(ctx, as: :user)}
end

def render(assigns) do
  ~H"""
  <.form for={@form} phx-change="validate" phx-submit="save">
    <.input field={@form[:name]} label="Name" />
    <.input field={@form[:email]} label="Email" />
    <.input field={@form[:age]} type="number" label="Age" />

    <div>
      <.button>Save</.button>
    </div>
  </.form>
  """
end

That's it! Phoenix's <.input> component automatically displays validation errors.

3. Handle Validation

Parse params on every change to show live validation:

def handle_event("validate", %{"user" => params}, socket) do
  ctx = Zoi.Form.parse(@user_schema, params)

  {:noreply, assign(socket, form: to_form(ctx, as: :user))}
end

4. Handle Submit

Check ctx.valid? and use ctx.parsed for validated data:

def handle_event("save", %{"user" => params}, socket) do
  ctx = Zoi.Form.parse(@user_schema, params)

  if ctx.valid? do
    # ctx.parsed is validated and type-coerced
    # Example: %{name: "John", email: "john@example.com", age: 30}
    case Accounts.create_user(ctx.parsed) do
      {:ok, user} ->
        {:noreply, push_navigate(socket, to: ~p"/users/#{user}")}

      {:error, _reason} ->
        # Handle submission errors - see "Adding External Errors" section below
        {:noreply, put_flash(socket, :error, "Failed to save")}
    end
  else
    # Show all errors immediately on submit
    form = to_form(ctx, as: :user, action: :validate)
    {:noreply, assign(socket, form: form)}
  end
end

5. Working with Nested data structures

Add nested addresses to your schema:

@user_schema Zoi.map(%{
  name: Zoi.string() |> Zoi.min(3),
  email: Zoi.email(),
  addresses: Zoi.array(
    Zoi.map(%{
      street: Zoi.string() |> Zoi.min(5),
      city: Zoi.string(),
      zip: Zoi.string() |> Zoi.length(5)
    })
  )
}) |> Zoi.Form.prepare()

Render with <.inputs_for>:

<.form for={@form} phx-change="validate" phx-submit="save">
  <.input field={@form[:name]} label="Name" />
  <.input field={@form[:email]} label="Email" />

  <.inputs_for :let={address} field={@form[:addresses]}>
    <.input field={address[:street]} label="Street" />
    <.input field={address[:city]} label="City" />
    <.input field={address[:zip]} label="ZIP" />

    <.button type="button" phx-click="remove_address" phx-value-index={address.index}>
      Remove
    </.button>
  </.inputs_for>

  <.button type="button" phx-click="add_address">Add Address</.button>

  <div>
    <.button>Save</.button>
  </div>
</.form>

6. Dynamic Add/Remove

To add or remove array items, work with form.params directly (arrays are always lists):

def handle_event("add_address", _params, socket) do
  # Get current addresses from form.params (always a list)
  addresses = socket.assigns.form.params["addresses"] || []

  # Add a new empty address
  updated_params = Map.put(socket.assigns.form.params, "addresses", addresses ++ [%{}])

  # Re-parse and update form
  ctx = Zoi.Form.parse(@user_schema, updated_params)

  {:noreply, assign(socket, form: to_form(ctx, as: :user))}
end

def handle_event("remove_address", %{"index" => index}, socket) do
  addresses = socket.assigns.form.params["addresses"] || []
  idx = String.to_integer(index)

  # Remove the address at the index
  updated_params = Map.put(socket.assigns.form.params, "addresses", List.delete_at(addresses, idx))

  # Re-parse and update form
  ctx = Zoi.Form.parse(@user_schema, updated_params)

  {:noreply, assign(socket, form: to_form(ctx, as: :user))}
end

Note: Zoi.Form.parse/2 automatically converts LiveView's map-based array format to lists, so form.params always contains clean data you can manipulate with standard list operations.

7. Handle Create and Edit

Use handle_params to handle both :new and :edit actions:

def handle_params(params, _url, socket) do
  {:noreply, apply_action(socket, socket.assigns.live_action, params)}
end

defp apply_action(socket, :new, _params) do
  # Start with one empty address
  params = %{"addresses" => [%{}]}
  ctx = Zoi.Form.parse(@user_schema, params)

  socket
  |> assign(:page_title, "New User")
  |> assign(:user, nil)
  |> assign(:form, to_form(ctx, as: :user))
end

defp apply_action(socket, :edit, %{"id" => id}) do
  user = Accounts.get_user!(id)

  # Convert database record to form params (all strings)
  # You may also use Zoi to help with this conversion if needed
  # But in general, good to map manually for clarity
  params = %{
    "name" => user.name,
    "email" => user.email,
    "age" => user.age,
    "addresses" => Enum.map(user.addresses, fn addr ->
      %{
        "street" => addr.street,
        "city" => addr.city,
        "zip" => addr.zip
      }
    end)
  }

  ctx = Zoi.Form.parse(@user_schema, params)

  socket
  |> assign(:page_title, "Edit User")
  |> assign(:user, user)
  |> assign(:form, to_form(ctx, as: :user))
end

Update save to dispatch based on action:

def handle_event("save", %{"user" => params}, socket) do
  ctx = Zoi.Form.parse(@user_schema, params)

  if ctx.valid? do
    save_user(socket, socket.assigns.live_action, ctx.parsed)
  else
    {:noreply, assign(socket, form: to_form(ctx, as: :user, action: :validate))}
  end
end

defp save_user(socket, :new, attrs) do
  case Accounts.create_user(attrs) do
    {:ok, user} ->
      {:noreply,
       socket
       |> put_flash(:info, "User created")
       |> push_navigate(to: ~p"/users/#{user}")}

    {:error, _changeset} ->
      {:noreply, put_flash(socket, :error, "Failed to create")}
  end
end

defp save_user(socket, :edit, attrs) do
  case Accounts.update_user(socket.assigns.user, attrs) do
    {:ok, user} ->
      {:noreply,
       socket
       |> put_flash(:info, "User updated")
       |> push_navigate(to: ~p"/users/#{user}")}

    {:error, _changeset} ->
      {:noreply, put_flash(socket, :error, "Failed to update")}
  end
end

8. Adding External Errors to Forms

When your backend returns errors (e.g., from Ecto changesets or business logic), you can add them to the Zoi context so they display on the form.

Since you already have the context from Zoi.Form.parse/2, use Zoi.Context.add_error/2 to add errors before converting to a form:

def handle_event("save", %{"user" => params}, socket) do
  ctx = Zoi.Form.parse(@user_schema, params)

  if ctx.valid? do
    case Accounts.create_user(ctx.parsed) do
      {:ok, user} ->
        {:noreply, push_navigate(socket, to: ~p"/users/#{user}")}

      {:error, :email_taken} ->
        error = Zoi.Error.custom_error(issue: {"has already been taken", []}, path: [:email])
        ctx = Zoi.Context.add_error(ctx, error)
        {:noreply, assign(socket, form: to_form(ctx, as: :user, action: :validate))}

      {:error, _reason} ->
        {:noreply, put_flash(socket, :error, "Failed to save")}
    end
  else
    {:noreply, assign(socket, form: to_form(ctx, as: :user, action: :validate))}
  end
end

Adding Changeset Errors

Ecto changeset errors use the {msg, opts} tuple format. Convert them to Zoi errors preserving the format for translation support (see Localizing errors with Gettext):

defp save_user(socket, :new, attrs, ctx) do
  case Accounts.create_user(attrs) do
    {:ok, user} ->
      {:noreply,
       socket
       |> put_flash(:info, "User created")
       |> push_navigate(to: ~p"/users/#{user}")}

    {:error, changeset} ->
      ctx = add_changeset_errors(changeset, ctx)
      {:noreply, assign(socket, form: to_form(ctx, as: :user, action: :validate))}
  end
end

defp add_changeset_errors(changeset, ctx) do
  Enum.reduce(changeset.errors, ctx, fn {field, {msg, opts}}, acc ->
    error = Zoi.Error.custom_error(issue: {msg, opts}, path: [field])
    Zoi.Context.add_error(acc, error)
  end)
end

The Ecto schema might not always represent the form structure you created with Zoi, so depending on your use case, you need to map fields accordingly.