A LiveComponent version of the DynamicForm renderer with automatic state management.
This component handles form state, validation, and submission automatically, communicating with the parent LiveView via message passing.
Attributes
Required
:id- Component ID (string, required by LiveComponent):instance- DynamicForm.Instance struct, JSON string, or map containing form configuration
Optional
:on_change- 1-arity function(payload) -> payloadrun after the built-in validations on every change and during the submit validation pass (default:nil; see "Lifecycle callbacks" below):change_debounce_in_ms- Milliseconds of quiet before a change runson_changeand sends its:changemessage; without it both happen on every change (integer, default:nil; see "Debouncing changes" below):on_submit- 1-arity function(payload) -> payloadrun on every submit — valid or not (default:nil; see "Lifecycle callbacks" below):on_success- 1-arity function(payload)run on every valid submission instead of the{:dynamic_form, :success, payload}message to the parent LiveView (default:nil; see "Messages" below):send_message_on- Lifecycle events that message the parent LiveView: any of[:success, :change, :submit](list, default:[:success]; see "Messages" below):data- Initial form data for edit mode — existing record values; a payload'sdataround-trips directly (map, default:%{}):form_name- Form namespace for submitted params (string, default:"dynamic_form"):submit_text- Submit button text (string, default:"Submit", not required whenhide_submitistrue):hide_submit- Whether to hide the submit button (boolean, default:false):gettext- Gettext backend module for translations (atom, default:DynamicForm.Gettext):validation_summary- Display validation errors at top of form (string,nil,"simple", or"detailed", default:nil):components- Custom components module (e.g. the app's Phoenix-generated CoreComponents); functions it exports override the built-ins per function — seeDynamicForm.Components(atom, default:nil, falling back to the:dynamic_form, :componentsconfig):custom_field_types- Custom field types map (type name => Ecto type), merged over the:dynamic_form, :custom_field_typesconfig — seeDynamicForm.FieldTypes(map, default:nil)
Usage
Basic Usage
The component sends the parent LiveView a message on every valid submission — the parent performs the side effect:
<.live_component
module={DynamicForm.RendererLive}
id="contact-form"
instance={@form_instance}
/>
def handle_info({:dynamic_form, :success, %DynamicForm.Payload{data: data}}, socket) do
{:ok, contact} = MyApp.Contacts.create_contact(data)
{:noreply, put_flash(socket, :info, "Created contact #{contact.id}")}
endCustom success handling
Define on_success to replace the default message with your own behavior
(a differently-shaped message, a PubSub broadcast, or nothing at all):
<.live_component
module={DynamicForm.RendererLive}
id="contact-form"
instance={@form_instance}
on_success={fn payload -> send(self(), {:contact_saved, payload.data}) end}
/>Edit Mode
Pre-populate the form with existing data:
<.live_component
module={DynamicForm.RendererLive}
id="user-profile"
instance={@form_instance}
data={%{"name" => "John", "email" => "john@example.com"}}
form_name="user_profile"
/>Disabled Fields
Fields can be marked as disabled: true in the form instance configuration.
Disabled fields are displayed but cannot be edited by the user.
Important: Disabled HTML fields are not submitted by browsers, so their values
are automatically preserved by merging the initial :data with form submissions.
This ensures disabled field values remain in the changeset throughout validation
and submission.
External Submit Button
You can place a submit button outside the form element by using the hide_submit
option and DynamicForm.RendererLive.submit_button/1:
<DynamicForm.RendererLive.submit_button form="my-form-form">
Save Changes
</DynamicForm.RendererLive.submit_button>
<.live_component
module={DynamicForm.RendererLive}
id="my-form"
instance={@form_instance}
hide_submit={true}
/>Note: The form ID is automatically generated as "#{id}-form", so if your component
ID is "my-form", the form element ID will be "my-form-form".
Lifecycle callbacks: on_change and on_submit
Both hooks mirror the form's phx-change/phx-submit events. Each is a
1-arity function that receives a DynamicForm.Payload — carrying the
changeset after the built-in validations and the applied data — and
returns the payload, transformed or untouched. Callbacks are for
validation, not side effects: perform actions (database writes,
navigation) in the parent's handle_info/2 instead.
on_change extends validation: it runs after the built-in validations on
every change (and during the submit validation pass). Errors added via
DynamicForm.Payload.add_error/4 render inline live, exactly like
built-in validations. Keep it cheap — it runs per keystroke.
on_submit runs on every submit — valid or not — so it can batch
expensive checks (API calls, database lookups) with the built-in errors
into one complete error list:
<.live_component
module={DynamicForm.RendererLive}
id="contact-form"
instance={@form_instance}
on_submit={&MyApp.Contacts.verify/1}
/>
def verify(payload) do
case verify_phone_number(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
endDebouncing changes
change_debounce_in_ms trades immediacy for fewer runs: on_change and
the :change message wait for the given milliseconds of quiet instead of
firing on every change.
<.live_component
module={DynamicForm.RendererLive}
id="signup-form"
instance={@form_instance}
on_change={&MyApp.Accounts.check_availability/1}
change_debounce_in_ms={300}
/>The built-in validations still render 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, so work that costs
more than a keystroke can afford (a database lookup, an API call, a parent
re-render per :change message) happens once the user pauses rather than
once per character.
Between the change and the deferred run the callback's errors are absent:
the changeset is rebuilt from the new params on every change, and the
callback that would re-add them hasn't run yet. Submitting always runs
on_change inline, so a debounced callback can never be skipped by
submitting during the quiet period.
A nil or 0 interval runs both inline, exactly as if the attribute were
absent.
Messages
The component messages the parent LiveView with a lifecycle event and the payload:
{:dynamic_form, event, %DynamicForm.Payload{}}send_message_on picks the events, defaulting to [:success]:
:success- a valid submission:change- every change, after the built-in validations andon_change(debounced bychange_debounce_in_ms):submit- every submit, valid or not, afteron_submit
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. The payload delivered is the one returned by the callbacks (or built
by the component when none are configured), so anything stashed in
:extra is available in handle_info/2.
Defining on_success replaces the success message: the function is
called with the payload on every valid submission and no :success
message is sent. Its return value is ignored. Use it to send a custom
message, broadcast over PubSub, or make the form fully self-contained.
Listing :success in send_message_on alongside on_success raises.
Messages go to the LiveView process, so a LiveComponent parent can't
receive them — have the callbacks call Phoenix.LiveView.send_update/2
instead.
Summary
Functions
Renders a submit button that can be placed outside a form element.
Functions
Renders a submit button that can be placed outside a form element.
Uses the HTML form attribute to associate the button with a form by its ID.
This allows the submit button to be placed anywhere on the page, not just
inside the form element.
When using with DynamicForm.RendererLive, the form ID is automatically
generated as "#{component_id}-form". For example, if your LiveComponent
has id="my-form", the form element ID will be "my-form-form".
Examples
# LiveComponent with external submit button
<DynamicForm.RendererLive.submit_button form="contact-form-form">
Submit Contact Form
</DynamicForm.RendererLive.submit_button>
<.live_component
module={DynamicForm.RendererLive}
id="contact-form"
instance={@form_instance}
hide_submit={true}
/>
# In a modal footer
<.modal id="edit-modal">
<.live_component
module={DynamicForm.RendererLive}
id="user-profile"
instance={@form_instance}
hide_submit={true}
/>
<:actions>
<DynamicForm.RendererLive.submit_button form="user-profile-form">
Save Profile
</DynamicForm.RendererLive.submit_button>
</:actions>
</.modal>Attributes
form- The ID of the form element to submit (required)class- Additional CSS classes to apply to the buttondisabled- Whether the button is disabled
Attributes
form(:string) (required) - The ID of the form element to submit.class(:string) - Additional CSS classes. Defaults tonil.disabled(:boolean) - Whether the button is disabled. Defaults tofalse.- Global attributes are accepted. Supports all globals plus:
["name", "value"].
Slots
inner_block(required)