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.object(%{
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} ->
# 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
end5. 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))}
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
end