DynamicForm - A Phoenix LiveView library for creating dynamic forms with full server-side validation using changesets. Also supports building forms through a WYSIWYG interface.
This library enables users to build forms dynamically through a visual interface, then render those forms using standard Phoenix LiveView patterns with robust validation and submission handling.
Dynamic Forms
DynamicForm.form/1 is the unified entry point for rendering forms. It
accepts a declarative definition using <:field> slots, a
SurveyJS-compatible JSON string, or an instance using Elixir data structures:
<%!-- FromComponent --%>
<DynamicForm.form id="contact-form">
<:field type="text" input_type="email" name="email" label="Email" required />
</DynamicForm.form>
<%!-- Data: SurveyJS-compatible JSON string --%>
<DynamicForm.form id="contact-form" json={@json} />
<%!-- Data: Instance struct or map --%>
<DynamicForm.form id="contact-form" instance={@form_instance} />
Summary
Functions
Renders a dynamic form from an instance, a SurveyJS-compatible JSON string,
or <:field> slots (declarative mode).
The whole form's current values, for reading inside a <:field> slot body.
Functions
Renders a dynamic form from an instance, a SurveyJS-compatible JSON string,
or <:field> slots (declarative mode).
Wraps DynamicForm.Renderer.LiveComponent, which manages form state, validation, and
submission. Exactly one of the instance attribute, the json attribute,
or <:field> slots must be provided.
Messages
When a user changes a value or submits the form, the library validates the changes with it's own internal state and then sends messages to the parent LiveView or LiveComponent.
The messages are in the format of {:dynamic_form, event, payload}.
By default, the form will only send the :success event. Which only occurs
when the user submits a form with no validation errors.
<DynamicForm.form id="signup">
def handle_info({:dynamic_form, :success, payload}, socket) do
{:ok, record} = Context.create(payload.data)
{:noreply, push_navigate(socket, to: ~p"/success")}
endIt is also possible to receive :change and :submit events too. The
send_messages_on attribute can be used to define a list of events to
receive:
<DynamicForm.form id="signup" send_message_on={[:change, :success]}>
def handle_info({:dynamic_form, :change, payload}, socket) do
# ...
end
def handle_info({:dynamic_form, :success, payload}, socket) do
# ...
endWhen using :change and :submit the form data may be invalid. The
DynamicForm.Payload.valid?/1 helper is available to check valid state.
Note: The :change event can also be paired with the change_debounce_in_ms
to add a debounce filter to the change events sent. Without it, every change
will send a message.
See DynamicForm.Renderer.LiveComponent and DynamicForm.Payload for the full details.
Lifecycle callbacks
Receiving messages is the standard way to work with data coming from DynamicForm. Most form flows do not require any additional changes to the form's lifecycle - the library defaults are sufficient.
For some more complex form flows, it's useful to be able to hook into the internal lifecycle events that happen within the library.
In those cases, there are two optional validation hooks mirror the form's
phx-change/phx-submit events. Each is a 1-arity function receiving a
DynamicForm.Payload and returning it, transformed or untouched. Reject a
submission with DynamicForm.Payload.add_error/4; side effects belong in the
parent's handle_info/2:
on_change— runs after the built-in validations on every change (and during the submit validation pass). Keep it cheap — it runs per keystroke, unlesschange_debounce_in_msis set.on_submit— runs on every submit, valid or not, so it can batch expensive checks with the built-in errors into one complete error list.<DynamicForm.form id="contact-form" on_submit={&Contacts.verify/1}> <:field type="text" name="email" label="Email" required format="email" /> </DynamicForm.form>
change_debounce_in_ms waits for that many milliseconds of quiet before
running the change work, so a callback too expensive for a keystroke runs
once the user pauses. The built-in validations still render on every
change, and submitting always runs the callback inline:
<DynamicForm.form id="signup" on_change={&Accounts.check_availability/1} change_debounce_in_ms={300}>
<:field type="text" name="username" label="Username" required />
</DynamicForm.form>FromComponent mode
<:field> entries parse to a DynamicForm.Instance in template order
(see DynamicForm.Parser.FromComponent). Question types collect input;
html, image, and custom render static or custom content:
<DynamicForm.form id="signup">
<:field type="html" name="intro" html="<h2>Sign up</h2>" />
<:field type="text" name="email" label="Email" format="email" required />
<:field type="rating" name="score" label="Score" rate_min={1} rate_max={10} />
</DynamicForm.form>Groups (panels)
Fields sharing a group attribute are collected into a panel declared by a
<:group> entry. The panel renders at the position of its first member:
<:group name="address" title="Shipping Address" visible_if="{ship} = true" />
<:field group="address" type="text" name="street" label="Street" />
<:field group="address" type="text" name="city" label="City" />Nested forms (repeating entries)
A <:nested> entry declares a repeating child form; fields join it with
nested="name" and the submitted value becomes a list of maps, each entry
validated with its own changeset. nested declares a field's data scope
and group its visual grouping — they combine. See the Nested Forms guide:
<:nested name="addresses" title="Addresses" min_entries={1}
add_text="Add another address" />
<:field nested="addresses" type="text" name="street" label="Street" required />
<:field nested="addresses" type="text" name="city" label="City" required />Custom markup (slot bodies)
A <:field> body customizes rendering. Three tiers:
<%!-- Content block: body instead of the html attribute --%>
<:field type="html" name="intro">
<h2>Welcome, {@current_user.name}</h2>
</:field>
<%!-- Custom control: body receives the Phoenix.HTML.FormField; the
library still renders the label and errors, and the changeset
still validates the field --%>
<:field type="text" input_type="number" name="amount" label="Amount" :let={field}>
<input type="range" min="0" max="100" name={field.name} id={field.id}
value={field.value || 0} />
</:field>
<%!-- Fully custom element: body receives the Phoenix form --%>
<:field type="custom" name="summary" :let={form}>
<p>Total: {form[:amount].value}</p>
</:field>A body reads its own scope through the value it receives, and the whole
form — including other nested forms' entries — through
DynamicForm.form_data/1.
Slot bodies are in-memory only: instances containing them JSON-encode without the bodies, and such forms cannot round-trip through the WYSIWYG builder.
Render-only mode
The DynamicForm library can also be used as a pure renderer. In this setup,
the form is rendered as a functional component on the page. The standard
phx-change and phx-submit event handlers get sent directly to the parent
LiveView as-is. That means no LiveComponent or managed state. In other words,
no validation or type casting, no changeset or error management.
To enable this mode, add the render_only attribute.
Events are emitted without a phx-target, so they land in the parent
LiveView's handle_event/3 exactly like an idiomatic <form phx-change="validate" phx-submit="submit">, and the parent owns the form
state, passing its Phoenix.HTML.Form in:
<DynamicForm.form id="signup" render_only form={@form}>
<:field type="text" name="name" label="Name" required />
<:field type="text" name="email" input_type="email" label="Email" required />
</DynamicForm.form>
def handle_event("validate", %{"signup" => params}, socket) do
changeset = Accounts.change_user(%User{}, params) |> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset, as: "signup"))}
end
def handle_event("submit", %{"signup" => params}, socket) do
# entirely yours
endThe definition drives presentation — markup, labels, errors, conditional
visibility — while the parent's changeset drives the data. Override the
event names with phx_change and phx_submit.
Note: Lifecycle attributes (on_change, change_debounce_in_ms, on_submit,
on_success, send_message_on, data, form_name,
validation_summary) have no meaning in the render_only mode and
will raise an exception. File upload questions require the stateful component
and also raise.
Attributes
id(:string) (required) - Component ID; also the instance id in declarative mode.instance(:any) - Data mode: an Instance struct, JSON string, or map. Mutually exclusive with json and <:field> slots. Defaults tonil.json(:string) - Data mode: a SurveyJS-compatible JSON string, parsed with Parser.FromData.parse!/1. Mutually exclusive with instance and <:field> slots. Defaults tonil.title(:string) - Instance title (declarative mode). Defaults tonil.description(:string) - Instance description (declarative mode). Defaults tonil.on_change(:any) - 1-arity function (DynamicForm.Payload) -> DynamicForm.Payload, run after built-in validations on every change and during the submit validation pass. Defaults tonil.change_debounce_in_ms(:integer) - Milliseconds of quiet before a change runs on_change and sends its :change message; without it both happen on every change. Defaults tonil.on_submit(:any) - 1-arity function (DynamicForm.Payload) -> DynamicForm.Payload, run on every submit — valid or not. Defaults tonil.data(:map) - Initial form data for edit mode — existing record values (a payload's data round-trips directly). Defaults to%{}.form_name(:string) - Form namespace for submitted params. Defaults to"dynamic_form".submit_text(:string) - Submit button text. Defaults to"Submit".on_success(:any) - 1-arity function (DynamicForm.Payload), run on every valid submission instead of sending the {:dynamic_form, :success, payload} message. Defaults tonil.send_message_on(:list) - Lifecycle events that message the parent LiveView: any of [:success, :change, :submit] (default: [:success]). Defaults tonil.hide_submit(:boolean) - Hide the submit button. Defaults tofalse.gettext(:atom) - Gettext backend for translations. Defaults toDynamicForm.Gettext.components(:atom) - Custom components module (e.g. the app's Phoenix-generated CoreComponents); functions it exports override the built-ins per function. Falls back to the :dynamic_form, :components config — see DynamicForm.ComponentResolver. Defaults tonil.custom_field_types(:map) - Custom field types map (type name => Ecto type), merged over the :dynamic_form, :custom_field_types config; rendering dispatches to the components module's input/1 — see DynamicForm.FieldTypes. Defaults tonil.validation_summary(:string) - Display validation errors at the top of the form: nil, "simple", or "detailed". Defaults tonil.render_only(:boolean) - Render the form markup only: events go to the parent LiveView's handle_event/3 and the parent owns the form state. Requires form. Defaults tofalse.form(:any) - Render-only mode: the parent-owned Phoenix.HTML.Form to render against. Defaults tonil.phx_change(:string) - Render-only mode: change event name (default "validate"). Defaults tonil.phx_submit(:string) - Render-only mode: submit event name (default "submit"). Defaults tonil.
Slots
field- Form elements in render order (declarative mode). Accepts attributes:type(:string) (required) - Question or element type: text, comment, dropdown, radiogroup, checkbox, boolean, rating, tagbox, file, html, image, custom, or a registered custom field type (validated at runtime).name(:string) - Field name (required for question types; auto-generated for html/image/custom).label(:any) - Question title / image alt text. Blank (nil, false, or "") renders no label, and no required marker with it; omitting it falls back to the name.placeholder(:string)description(:string) - Help text shown below the input.input_type(:string) - HTML input type for type="text" (email, number, ...).default(:any) - Default value seeded into the form params.options(:list) - Choices for dropdown/radiogroup/checkbox/tagbox: [{"Label", "value"}, ...] or ["value", ...].choices_from(:string) - Carry forward: build this field's choices from another question's values, typically a <:nested> form. Mutually exclusive with options.choice_value(:string) - Carry forward: the source field supplying each choice's value (default: the entry's dynamic_form_id).choice_text(:string) - Carry forward: the source field supplying each choice's label, or a template interpolating them — "{min} - {max}", "{panelIndex}".choices_mode(:string) - Carry forward from another choice field: "all" (default), "selected", or "unselected".no_choices_text(:string) - Shown in place of the control when the field has no choices yet — typically a carried-forward source with no entries.required(:boolean)required_label(:any) - Mark shown beside a required field's label (default "*"). Blank (nil, false, or "") shows none while the field stays required.required_if(:string) - SurveyJS expression, e.g. "{other} notempty".visible_if(:string) - SurveyJS expression, e.g. "{subject} = 'support'".enable_if(:string) - SurveyJS expression; disabled when false.read_only(:boolean)group(:string) - Collect this field into the <:group> panel with this name.nested(:string) - Data scope: collect this field into the <:nested> form with this name. Combines with group — see the Nested Forms guide.rate_min(:integer) - type="rating" only.rate_max(:integer) - type="rating" only.rate_step(:integer) - type="rating" only.min_length(:integer) - Text length validation.max_length(:integer) - Text length validation.min(:any) - Numeric range validation.max(:any) - Numeric range validation.pattern(:string) - Regex validation.format(:string) - Format validation; supported: "email".validators(:list) - Escape hatch: Instance.Validator structs or atom-keyed maps.html(:string) - Raw HTML content for type="html" (alternative to a slot body).src(:string) - type="image" only: image URL.width(:string) - type="image" only, e.g. "300px".height(:string) - type="image" only.fit(:string) - type="image" only: CSS object-fit value.metadata(:map) - Metadata map (file upload config, radiogroup style, ...).
group- Panel declarations referenced by <:field group="..."> entries. Accepts attributes:name(:string) (required)title(:any) - Panel heading; blank (nil, false, or "") renders none.type(:string) - Layout: "horizontal" (default, members share a row and wrap) or "vertical", or a type your components module defines.visible_if(:string)enable_if(:string)group(:string) - Place this group inside another <:group> panel. It renders at the position of its own first member field, and must declare the same nested scope as its parent.nested(:string) - Data scope this group lives in. A group inside a nested form declares it here, and every member field must declare the identical nested scope.
nested- Nested (repeating) form declarations referenced by <:field nested="..."> entries — the declarative counterpart to the SurveyJS paneldynamic question. Accepts attributes:name(:string) (required) - Data key: the value is a list of entry maps.title(:any) - Section heading; blank (nil, false, or "") renders none, while omitting it falls back to the capitalized name.description(:string) - Help text shown below the title.entry_title(:any) - Per-entry heading; "{panelIndex}" interpolates the 1-based entry number. Blank renders none.entries(:integer) - Entries seeded on a fresh form (default 0, raised to min_entries).min_entries(:integer) - Entries cannot be removed below this; validated on submit.max_entries(:integer) - The add button hides at this count; validated on submit.add_text(:string) - Add button label (default "Add new").remove_text(:string) - Remove button label (default "Remove"). The control is an icon, so this becomes its tooltip and screen-reader name.no_entries_text(:string) - Shown when the form has zero entries.confirm_delete(:boolean) - Ask for confirmation before removing an entry.confirm_text(:string) - Confirmation dialog text.key(:string) - Member field whose value must be unique across entries.key_error(:string) - Error message for key duplicates.generate_ids(:boolean) - Seed each entry with a stable dynamic_form_id, copied from the entry's id when the data came from a stored record (default true).default(:list) - Initial value: a list of entry maps (edit-mode style seeding).default_entry(:map) - Values seeded into each newly added entry.required(:boolean) - At least one entry is required.required_label(:any) - Mark beside the section heading when required (default "*"); blank shows none.visible_if(:string)enable_if(:string)nested(:string) - Place this nested form inside another <:nested> form's template.group(:string) - Place this nested form inside a <:group> panel.
@spec form_data(Phoenix.HTML.FormField.t() | Phoenix.HTML.Form.t()) :: map()
The whole form's current values, for reading inside a <:field> slot body.
Takes the Phoenix.HTML.FormField a custom control receives, or the
Phoenix.HTML.Form a type="custom" element receives, and returns the
applied changeset data — the same map a :change message delivers as
payload.data, with nested entries as lists of maps:
%{name: "Ada", addresses: [%{street: "110 Main St", city: "Portland"}]}The map is always form-level, even inside a nested entry, so a control in one nested form can read another's entries:
<:field :let={field} nested="rooms" type="checkbox" name="teachers">
<%= for teacher <- DynamicForm.form_data(field)[:staff] || [] do %>
<label>
<input type="checkbox" name={"#{field.name}[]"} value={teacher[:id]} />
{teacher[:name]}
</label>
<% end %>
</:field>Values the user hasn't entered are absent rather than nil, as with any
Ecto.Changeset.apply_changes/1 result — default to [] or %{} when
reading. In render-only mode the parent owns
the changeset, so the shape is whatever that changeset applies to.
Raises when given a form DynamicForm didn't render.