# Lifecycle Events

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

```elixir
{: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:

```elixir
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`:

```elixir
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:

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

```heex
<DynamicForm.form id="signup" on_change={&password_confirmation/1}>
```

```elixir
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:

```heex
<DynamicForm.form id="contact-form" on_submit={&verify_phone/1}>
```

```elixir
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:

```heex
<DynamicForm.form id="signup" on_success={&signup_complete/1}>
```

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

## More details

See the [Reference](reference.md#lifecycle-callback-contracts) 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](usage.md#render-only-mode): the definition renders
against a parent-owned form and the parent handles the
`phx-change`/`phx-submit` events itself.
