How a form moves through its lifecycle, how the parent LiveView is notified, and how to hook into the cycle.
The dynamic form library displays and validates forms automatically. Once a user submits a valid form, it sends a message to the parent LiveView instance with the submitted form data. This is the default behavior — no configuration needed.
Some flows require some additional functionality. Maybe it's making a call to a third-party service to validate a phone number or an expensive database query where the result should be bubbled up as a validation error on a specific field on the form.
In those cases, use on_change and on_submit to hook into the dynamic form
lifecycle. Using these functions make it possible to add custom validators and
error messages that are shown alongside the existing validators and error
messages already being generated by the library. They extend validation —
side effects like inserting a record or navigating stay in the parent
LiveView's handle_info/2.
A third hook, on_success, replaces the default success message entirely for
the rare form that needs to complete some other way. To hear about changes
and submits in the parent as well, opt into their messages with
send_message_on.
Handling messages on form submit
The component messages the parent LiveView with a lifecycle event and the payload:
{:dynamic_form, event, %DynamicForm.Payload{}}By default the only event is :success, a valid submission — invalid ones
render their errors inline instead. This is where the side effect happens.
Other events are opt-in.
Here's a look at a complete LiveView:
defmodule MyAppWeb.ContactLive do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<DynamicForm.form id="contact-form">
<:field type="text" name="name" label="Name" required />
<:field type="text" name="email" input_type="email" label="Email Address" required format="email" />
<:field type="comment" name="message" label="Message" required />
</DynamicForm.form>
</Layouts.app>
"""
end
@impl true
def handle_info({:dynamic_form, :success, %DynamicForm.Payload{data: data}}, socket) do
{:ok, contact} = MyApp.Contacts.create_contact(data)
{:noreply,
socket
|> put_flash(:info, "Created contact #{contact.id}")
|> push_navigate(to: ~p"/thank-you")}
end
endWhen a page renders several forms, match on the payload's id:
def handle_info({:dynamic_form, :success, %DynamicForm.Payload{id: "contact-form"} = payload}, socket) do
# ...
end
def handle_info({:dynamic_form, :success, %DynamicForm.Payload{id: "signup-form"} = payload}, socket) do
# ...
endThis alone is sufficient for many use-cases: define the dynamic form instance, trust it to build the UI and data validation, and then work with the results of submitting the form in the parent LiveView instance.
Choosing which events message the parent
send_message_on picks the lifecycle events that message the parent — any
combination of [:success, :change, :submit], defaulting to [:success]:
| Event | Sent |
|---|---|
:success | On a valid submission |
:change | On every change, after the built-in validations and on_change |
:submit | On every submit — valid or not — after on_submit |
<DynamicForm.form id="signup" send_message_on={[:success, :change]}>def handle_info({:dynamic_form, :change, payload}, socket) do
{:noreply, assign(socket, :preview, payload.data)}
endA valid submission with all three enabled delivers :change, :submit, and
:success, in that order. :change and :submit payloads are routinely
invalid — check DynamicForm.Payload.valid?/1 before acting on them.
Every :change message wakes the parent for a render, so pair :change with
change_debounce_in_ms on forms where a message per
keystroke is more than you need.
The payload
Every message carries a DynamicForm.Payload struct:
| Field | Value |
|---|---|
id | The form component's id, for matching in handle_info/2 |
changeset | The form's Ecto.Changeset — its valid? flag is the source of truth for validity, and always true for :success |
data | The applied changeset data (Ecto.Changeset.apply_changes/1) |
extra | Empty by default; on_submit can stash derived data here |
Optional enhancement: on_change
Add on_change to hook into the dynamic form lifecycle itself.
Use it when the form needs validation the built-in validators can't
express — cross-field rules, business logic. It's a 1-arity function
receiving a DynamicForm.Payload that runs after the built-in validations on
every change (and during the submit validation pass) and returns the payload.
Errors added with DynamicForm.Payload.add_error/4 behave exactly like
built-in ones: they render inline and clear in realtime as the user fixes
fields.
<DynamicForm.form id="signup" on_change={&password_confirmation/1}>defp password_confirmation(payload) do
if payload.data[:password] == payload.data[:password_confirmation] do
payload
else
DynamicForm.Payload.add_error(payload, :password_confirmation, "does not match")
end
endThe on_change function is called whenever the form value changes, just like
phx-change. It's also run when the user hits the submit button.
Since it's run often, keep the checks simple and fast to execute. Do the more
expensive checks only after the user hits the submit button, using on_submit
— or debounce them, as below.
Debouncing changes
Some checks belong live on the form but cost more than a keystroke can
afford: a uniqueness lookup, a call to a pricing service. Add
change_debounce_in_ms to wait for that many milliseconds of quiet before
running the callback:
<DynamicForm.form id="signup" on_change={&check_availability/1} change_debounce_in_ms={300}>defp check_availability(payload) do
if Accounts.username_taken?(payload.data[:username]) do # a query per run
DynamicForm.Payload.add_error(payload, :username, "is already taken")
else
payload
end
endThe interval covers the whole change pass — on_change and the :change
message together. The rest of the lifecycle is unchanged:
- Built-in validations still run on every change. Only the callback and the message are deferred, so a debounced form is as responsive as an undebounced one.
- Each change supersedes the pending run. Typing eight characters in a burst runs the callback once, not eight times.
- Submitting always runs the callback inline. A user who submits during the quiet period still gets the callback's errors — a debounced check can't be skipped by submitting quickly.
The one visible difference: between a change and its deferred run, errors the callback added are absent. Every change rebuilds the changeset from the new params, and the callback that would re-add them hasn't run yet — so a debounced error clears while the user types and returns once they pause. Errors from the built-in validators are unaffected.
Pick the interval from what the change pass costs: a few hundred milliseconds
suits a per-keystroke query. nil (the default) and 0 both run it inline,
so a computed interval can turn debouncing off without a separate branch in
the template.
Optional enhancement: on_submit
Add on_submit to hook into the dynamic form lifecycle itself.
Use it for more expensive validation when the form itself is submitted. It's
a 1-arity function receiving a DynamicForm.Payload that runs on every
submit — valid or not — so it can batch expensive checks with the built-in
errors into one complete error list. Uniqueness-style checks that a context
would normally catch at insert time belong here, so their errors render on
the form:
<DynamicForm.form id="contact-form" on_submit={&verify_phone/1}>def verify_phone(payload) do
case PhoneService.verify(payload.data[:phone]) do # expensive, submit-only
{:ok, normalized} ->
DynamicForm.Payload.put_extra(payload, :normalized_phone, normalized)
:error ->
DynamicForm.Payload.add_error(payload, :phone, "is not a valid phone number")
end
endTo reject a submission, use DynamicForm.Payload.add_error/4 — validity
lives on the changeset, so adding an error is all it takes to mark the
submission invalid. To pass derived data forward without a side effect, use
DynamicForm.Payload.put_extra/3 — the value arrives in the parent's
handle_info/2 on the same payload.
When the returned payload is valid, it flows into the same
{:dynamic_form, :success, payload} message above, so the parent's
handle_info/2 handlers don't change when callbacks are added. When it's
invalid, the errors render inline and no :success message is sent —
on_submit is a validation gate, not the place for the action itself.
Optional override: on_success
Most forms should let the default {:dynamic_form, :success, payload}
message do its job. For the rare form that needs to complete some other way,
define on_success — a 1-arity function called with the payload on every
valid submission instead of sending that message. Its return value is
ignored:
<DynamicForm.form id="signup" on_success={&signup_complete/1}>defp signup_complete(payload) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "signups", {:signup, payload.data})
endUse it to send a differently-shaped message, broadcast over PubSub, or — with
an empty function — make the form fully self-contained. Unlike on_change
and on_submit, which run alongside the built-in behavior, on_success
replaces it. Listing :success in send_message_on alongside on_success
raises, since the two ask for opposite things.
Example: Using callbacks to send updates to a LiveComponent instead of LiveView
Messages are delivered via send(self(), ...) to the parent LiveView's
handle_info/2. LiveComponents don't have handle_info/2 — they only
receive data through update/2. Use a callback with
Phoenix.LiveView.send_update/2 to route the payload back to the component:
defmodule MyAppWeb.ContactFormComponent do
use MyAppWeb, :live_component
# Pattern-match on the event sent by on_success
def update(%{event: "form_success", payload: payload}, socket) do
{:ok, _contact} = MyApp.Contacts.create(payload.data)
{:ok,
socket
|> assign(:submission_count, socket.assigns.submission_count + 1)
|> assign(:last_submission, payload.data)}
end
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_new(:submission_count, fn -> 0 end)
|> assign_new(:last_submission, fn -> nil end)}
end
def render(assigns) do
~H"""
<div>
<DynamicForm.form
id={"#{@id}-form"}
on_success={&handle_form_success(&1, @id)}
>
<:field type="text" name="name" label="Name" required />
<:field type="text" name="email" label="Email" format="email" required />
</DynamicForm.form>
</div>
"""
end
defp handle_form_success(payload, component_id) do
Phoenix.LiveView.send_update(MyAppWeb.ContactFormComponent, %{
id: component_id,
event: "form_success",
payload: payload
})
end
endThis works because on_success is called synchronously inside
RendererLive.handle_event("submit", ...), and all LiveComponents share
their parent LiveView's process — so send_update/2 (which targets
self()) delivers the message to the right place. After the event handler
completes, your component's update/2 fires with the event assigns.
on_change and on_submit route the same way — they're the LiveComponent
equivalent of the :change and :submit messages. Both must return the
payload, so call send_update/2 for the side effect and hand the payload
back untouched:
defp handle_form_change(payload, component_id) do
Phoenix.LiveView.send_update(MyAppWeb.ContactFormComponent, %{
id: component_id,
event: "form_change",
payload: payload
})
payload
endThe parent LiveView only needs to mount the component:
<.live_component module={MyAppWeb.ContactFormComponent} id="contact-form" />No handle_info/2 clause is needed in the parent.
More details
See the Reference for the callback signatures and the payload fields in table form.
Architecture
DynamicForm.form/1 (via DynamicForm.RendererLive) owns the full cycle
internally:
user types ──▶ phx-change ──▶ built-in validations ──▶ on_change(payload) ──▶ :change
(or adds/removes conditional logic re-evaluated (both deferred by
a nested entry) (inline errors display once the change_debounce_in_ms
form has been submitted) when set)
user submits ─▶ phx-submit ─▶ built-in validations ──▶ on_change(payload) ──▶ :change
│ (inline, debounced or not)
│
├─ on_submit(payload) when given ─────────▶ :submit [valid or not]
│
└─ changeset valid? ──▶ yes ──▶ :success to the parent
│ (or on_success(payload) when defined)
no ──▶ errors rendered inline, no :successValidation runs on every change and every submit — the component handles it
without involving the parent LiveView. on_change and on_submit extend
the cycle from the application side; the parent hears about whichever events
send_message_on lists and performs the side effect in handle_info/2,
unless on_success overrides how success completes.
To opt out of the managed lifecycle entirely, use
render-only mode: the definition renders
against a parent-owned form and the parent handles the
phx-change/phx-submit events itself.