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.object(%{
    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} ->
        # You can also show changeset errors here if applicable
        {: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.object(%{
  name: Zoi.string() |> Zoi.min(3),
  email: Zoi.email(),
  addresses: Zoi.array(
    Zoi.object(%{
      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