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.
Handling messages on form submit
By default, the component sends the parent LiveView a message on every valid submission:
{:dynamic_form, %DynamicForm.Payload{}}Invalid submissions never message the parent — the form renders their errors inline itself, so the parent only ever handles success. This is where the side effect happens.
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, %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, %DynamicForm.Payload{id: "contact-form"} = payload}, socket) do
# ...
end
def handle_info({:dynamic_form, %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.
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 final Ecto.Changeset — its valid? flag is the source of truth for validity, and always true for delivered messages |
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.
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, 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 the parent is never messaged — 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, 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 the default 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.
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)
conditional logic re-evaluated
(inline errors display once the form has been submitted)
user submits ─▶ phx-submit ─▶ built-in validations ──▶ on_change(payload)
│
├─ on_submit given ──▶ on_submit(payload) [valid or not]
│
└─ changeset valid? ──▶ yes ──▶ {:dynamic_form, payload} to the parent
│ (or on_success(payload) when defined)
no ──▶ errors rendered inline, no messageValidation 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 valid
submissions 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.