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
end

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

The payload

Every message carries a DynamicForm.Payload struct:

FieldValue
idThe form component's id, for matching in handle_info/2
changesetThe form's final Ecto.Changeset — its valid? flag is the source of truth for validity, and always true for delivered messages
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.

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, 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})
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.

Example: Using on_success to send updates to a LiveComponent instead of LiveView

The default {:dynamic_form, payload} message is 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 on_success 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.

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)
                              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 message

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