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
end

When 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
  # ...
end

This 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]:

EventSent
:successOn a valid submission
:changeOn every change, after the built-in validations and on_change
:submitOn 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)}
end

A 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:

FieldValue
idThe form component's id, for matching in handle_info/2
changesetThe form's Ecto.Changeset — its valid? flag is the source of truth for validity, and always true for :success
dataThe applied changeset data (Ecto.Changeset.apply_changes/1)
extraEmpty 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
end

The 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
end

The 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
end

To 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})
end

Use 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
end

This 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
end

The 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 :success

Validation 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.