Rendering forms with Phoenix
View SourceZoi 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()
endZoi.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>
"""
endThat'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))}
end4. 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
end5. 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))}
endNote: 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))
endUpdate 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
end8. 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
endAdding 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)
endThe 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.