npx claudepluginhub wunki/amplify --plugin ask-questions-if-underspecifiedThis skill uses the workspace's default tool permissions.
Build forms in LiveView that validate cleanly, recover from disconnects, and handle complex data shapes.
Enforces Phoenix LiveView best practices: @impl true callbacks, assign initialization in mount/handle_params, connected? checks, proper tuples, and two-phase rendering awareness. Invoke before LiveView modules or .heex templates.
Implements optimistic UI patterns in Elixir Phoenix LiveView: instant client feedback before server confirmation, loading states, rollback on failure, race guards, stream animations, undo, accessibility.
Provides Phoenix LiveView best practices: no DB queries in mount (called twice), load data in handle_params, security scopes, scoped PubSub topics, GenServer polling, async assigns, and gotchas.
Share bugs, ideas, or general feedback.
Build forms in LiveView that validate cleanly, recover from disconnects, and handle complex data shapes.
to_form/2, not raw changesets in assigns.used_input?/1 gates per-field.phx-change and an id.to_form/2, assign the result. Do not set action: :validate on mount.phx-change to rebuild the changeset with action: :validate and reassign the form. Use _target for conditional validation logic.phx-submit. On success, redirect or update assigns. On failure, reassign from the error changeset.id. Required for DOM stability, recovery, and component targeting.def mount(_params, _session, socket) do
changeset = Accounts.change_user(%User{})
{:ok, assign(socket, form: to_form(changeset))}
end
def handle_event("validate", %{"user" => params}, socket) do
form =
%User{}
|> Accounts.change_user(params)
|> to_form(action: :validate)
{:noreply, assign(socket, form: form)}
end
Setting action: :validate enables error display. Without it, errors exist in the changeset but are suppressed in the template.
def handle_event("save", %{"user" => params}, socket) do
case Accounts.create_user(params) do
{:ok, user} ->
{:noreply,
socket
|> put_flash(:info, "User created")
|> redirect(to: ~p"/users/#{user}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
<.form for={@form} id="user-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} label="Name" />
<.input field={@form[:email]} label="Email" phx-debounce="blur" />
<button type="submit" phx-disable-with="Saving...">Save</button>
</.form>
Every form needs a unique id. Without it, DOM patching may replace elements instead of updating them, causing focus loss and breaking recovery.
Error visibility has two independent layers. Both must pass for errors to appear.
Errors are suppressed when the changeset action is nil or :ignore. Set action: :validate in to_form to enable them:
# Errors suppressed (mount, no action set)
to_form(changeset)
# Errors enabled (validation)
to_form(changeset, action: :validate)
used_input?/1Even with action: :validate, errors only show for fields the user has interacted with (focused, typed, or submitted). The LiveView JS client tracks this automatically by sending _unused_<field> params for untouched fields.
# In a custom input component
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
The default <.input> component from Phoenix generators (1.7.18+) handles this internally. used_input?/1 requires Phoenix LiveView 0.20+. On earlier versions, errors show unconditionally once the changeset action is set.
A nested field group (like a date with year/month/day sub-fields) is considered "used" if any of its sub-fields have been interacted with.
action: :validate set in to_form? Check both validate and save handlers.used_input?/1 filters unused fields.changeset.errors directly.used_input?/1? The default <.input> handles this, custom components may not.Both are client-side rate limiters applied to any event binding.
| Attribute | Behavior | Default (when value omitted) |
|---|---|---|
phx-debounce="300" | Delays event until 300ms of inactivity | 300ms (when attribute value is empty string) |
phx-debounce="blur" | Delays event until field loses focus | n/a |
phx-throttle="1000" | Emits immediately, then at most once per 1s | 300ms (when attribute value is empty string) |
When a phx-submit fires, or a phx-change fires for a different input, all pending debounce/throttle timers are flushed. This ensures stale debounced events do not arrive after a submit.
phx-debounce="blur" or 500-1000ms. Avoids validation noise while typing.phx-debounce="300" (the default). Fast enough to feel responsive.phx-throttle="100" or similar. Emits immediately, then rate-limits.phx-throttle="1000" to prevent accidental rapid clicks.phx-keydown on unique keypresses always dispatches immediately regardless of throttle. Only key repeats are throttled.form="..." attribute to associate with a remote form had bugs before v1.1.15.When a LiveView socket disconnects and reconnects (network interruption, server deploy, crash), forms with both phx-change and an id automatically recover. The client re-sends the form's current input values as a phx-change event immediately after mount.
No extra code needed. The phx-change handler ("validate") runs with the recovered data:
<.form for={@form} id="user-form" phx-change="validate" phx-submit="save">
...
</.form>
For wizards or forms with server-side step state that cannot be inferred from inputs alone, use a dedicated recovery event:
<form id="wizard" phx-change="validate_step" phx-auto-recover="recover_wizard">
...
</form>
def handle_event("recover_wizard", params, socket) do
# Rebuild wizard state from the recovered input values
{:noreply, rebuild_wizard_state(socket, params)}
end
When form state cannot be meaningfully restored (e.g., a completed payment form):
<form id="payment" phx-change="validate" phx-auto-recover="ignore">
LiveReload plug or set code_reloader: false.push_patch during recovery caused issues before v1.1.9.Read references/nested-forms.md when the request involves inputs_for, dynamic add/remove fields, sort_param, drop_param, cast_embed, cast_assoc, or schemas without a primary key. Skip if the form has no nested associations or embeds.
on_replace: :delete is required on has_many and embeds_many when using sort/drop params.type="button" to prevent form submission.JS.dispatch("change") triggers the form's phx-change event, which rebuilds the changeset.<input type="hidden" name="list[emails_drop][]" /> outside inputs_for is required. Without it, removing the last item sends no drop param and the delete is silently ignored.form[:field].value in nested forms for display logic. The value may be a struct, changeset, or raw params depending on state. Compute derived values in the LiveView or changeset instead.sort_param and drop_param require Ecto 3.10.0+.def mount(_params, _session, socket) do
{:ok,
socket
|> assign(form: to_form(MyApp.change_post(%Post{})))
|> allow_upload(:photos,
accept: ~w(.jpg .jpeg .png),
max_entries: 3,
max_file_size: 5_000_000
)}
end
The form must have both phx-change and phx-submit bindings for uploads to work.
<.form for={@form} id="post-form" phx-change="validate" phx-submit="save">
<.live_file_input upload={@uploads.photos} />
<%!-- Preview entries --%>
<div :for={entry <- @uploads.photos.entries}>
<.live_img_preview entry={entry} />
<progress value={entry.progress} max="100"><%= entry.progress %>%</progress>
<button type="button" phx-click="cancel-upload" phx-value-ref={entry.ref}>
Cancel
</button>
</div>
<%!-- Show errors --%>
<div :for={err <- upload_errors(@uploads.photos)} class="text-red-600">
<%= humanize_upload_error(err) %>
</div>
<button type="submit" phx-disable-with="Uploading...">Save</button>
</.form>
Files are uploaded before the phx-submit callback fires. Consume them in the submit handler:
def handle_event("save", params, socket) do
uploaded_files =
consume_uploaded_entries(socket, :photos, fn %{path: path}, entry ->
dest = Path.join(["priv", "static", "uploads", "#{entry.uuid}-#{entry.client_name}"])
File.cp!(path, dest)
{:ok, ~p"/uploads/#{Path.basename(dest)}"}
end)
# uploaded_files is a list of the {:ok, value} return values
# Save post with file paths...
end
allow_upload(socket, :avatar,
accept: :any,
auto_upload: true,
progress: &handle_progress/3
)
defp handle_progress(:avatar, entry, socket) do
if entry.done? do
url =
consume_uploaded_entry(socket, entry, fn %{path: path} ->
dest = Path.join(["priv", "static", "uploads", Path.basename(path)])
File.cp!(path, dest)
{:ok, ~p"/uploads/#{Path.basename(dest)}"}
end)
{:noreply, assign(socket, :avatar_url, url)}
else
{:noreply, socket}
end
end
def handle_event("cancel-upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :photos, ref)}
end
| Error | Meaning |
|---|---|
:too_large | File exceeds max_file_size |
:not_accepted | File type not in accept list |
:too_many_files | Exceeds max_entries |
:external_client_failure | External upload (S3, etc.) failed |
consume_uploaded_entries while entries are still uploading. Raises ArgumentError.max_file_size is enforced server-side chunk by chunk. Client metadata is untrusted.priv/static/uploads triggers LiveReload in dev and does not work in multi-instance production. Use external storage for production.<.form for={@form} id={"card-#{@card.id}"} phx-submit="save" phx-target={@myself}>
<.input field={@form[:title]} />
<button type="submit">Save</button>
</.form>
@myself is only available in stateful live_component renders. It routes phx-submit and phx-change events to the component's handle_event/3.
If a component's form modifies data owned by the parent, the component must notify the parent after saving. Never let component state diverge from parent state.
# In the component
def handle_event("save", %{"card" => params}, socket) do
socket.assigns.on_save.(params)
{:noreply, socket}
end
<%!-- In the parent --%>
<.live_component
module={CardFormComponent}
id={"card-#{card.id}"}
card={card}
on_save={fn params -> send(self(), {:save_card, card.id, params}) end}
/>
Use a live_component only when the form needs its own state or event handling (e.g., independent validation, local UI state). If the form just renders inputs and the parent handles all events, use a plain function component. Components add overhead for state tracking.
phx-trigger-actionFor operations that require a traditional HTTP request (session mutation, OAuth redirect), validate in LiveView then trigger an HTTP form submission:
<.form
for={@form}
id="login-form"
action={~p"/users/log_in"}
phx-change="validate"
phx-submit="save"
phx-trigger-action={@trigger_submit}
>
<.input field={@form[:email]} />
<.input field={@form[:password]} type="password" />
<button type="submit">Log in</button>
</.form>
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(%{}, as: :user), trigger_submit: false)}
end
def handle_event("validate", %{"user" => params}, socket) do
form = to_form(params, as: :user, action: :validate)
{:noreply, assign(socket, form: form)}
end
def handle_event("save", %{"user" => params}, socket) do
case validate_credentials(params) do
:ok ->
{:noreply, assign(socket, trigger_submit: true)}
{:error, message} ->
# Reset trigger_submit to false so a subsequent retry does not immediately re-submit
{:noreply, socket |> assign(trigger_submit: false) |> put_flash(:error, message)}
end
end
When phx-trigger-action becomes true, LiveView disconnects and submits the form via HTTP POST to the action URL. Always reset trigger_submit back to false in failure paths — leaving it true causes the form to re-submit immediately on the next render.
LiveView does not receive change events for invalid number input values. Browsers do not fire input events for them. Once the value becomes valid, events fire normally.
Workaround for full control: use a text input with numeric constraints:
<input type="text" inputmode="numeric" pattern="[0-9]*" name="amount" />
Values are never reused from the server for security. You must explicitly set the value:
<.input field={@form[:password]} type="password" value={@form[:password].value} />
LiveView will never overwrite the value of a focused input, even if the server sends different data. This prevents the user from losing what they're typing during validation round-trips.
A type="reset" button clears all inputs client-side and triggers phx-change with _target containing the reset button's name:
def handle_event("validate", %{"_target" => ["reset"]} = params, socket) do
# Handle form reset: rebuild changeset from default values
{:noreply, assign(socket, form: to_form(Accounts.change_user(%User{})))}
end
_target paramEvery phx-change event includes a "_target" key indicating which input triggered the change:
# User typed in the username field
%{"_target" => ["user", "username"], "user" => %{"username" => "Name"}}
Use this for conditional validation (e.g., only run expensive uniqueness checks when the relevant field changes).
Lifecycle:
to_form/2. Changesets are single-use; to_form normalizes them for the template.:let={f} with <.form> in LiveView. This bypasses change tracking optimizations.id on forms. Causes DOM patching issues and breaks recovery.Validation:
action: :validate on mount. Shows errors before the user has done anything.action: :validate during validation. Errors exist but are invisible.used_input?/1. Shows errors for untouched fields.Recovery:
phx-change alone enables recovery. The form also needs an id.Nested forms:
on_replace: :delete on has_many/embeds_many. Sort/drop params silently fail.inputs_for. Removing the last item is ignored.type="submit" on add/remove buttons. Triggers form submission instead of phx-change.references/nested-forms.md for the full list of nested-form pitfalls.phx-trigger-action:
trigger_submit to false in error branches. Causes the form to re-submit on the next render.Uploads:
consume_uploaded_entries before uploads complete. Raises ArgumentError.priv/static. Triggers LiveReload in dev, does not scale in production.Components:
live_component when a function component suffices. Adds unnecessary state tracking overhead.liveview-optimistic-ui skill.