DynamicForm

Dynamic forms for Phoenix LiveView with built-in validation - defined declaratively in HEEx or as (SurveyJS-compatible) data

Architecture

DynamicForm renders complete, validated forms from a single definition. It follows common best practices in Elixir and Phoenix, leveraging Ecto schemas to validate and cast form data.

A dynamic form definition can be written two ways:

In declarative mode, everything is composed from slots:

Examples

A simple form

Forms are defined using the <DynamicForm.form /> component. It can either be defined in data or using component slots. The <:field /> slots are rendered in order they are defined.

The library runs the whole validation lifecycle itself and messages the parent LiveView on every valid submission — the handle_info/2 handler is where the side effect happens. The payload is a struct containing information about the form, including the data key which is map of the submitted data.

<DynamicForm.form id="example-form">
<:field type="text" name="name" label="Name" required />
<:field type="text" name="email" label="Email" input_type="email" format="email" required />
</DynamicForm.form>
def handle_info({:dynamic_form, payload}, socket) do
{:ok, contact} = Contacts.create_contact(payload.data)
{:noreply, put_flash(socket, :info, "Created contact #{contact.id}")}
end

Prefilling form data

Use the data attribute to prefill the form with existing data:

<DynamicForm.form id="example-form" data={%{email: "hello@world.com"}}>
<:field type="text" name="name" label="Name" required />
<:field type="text" name="email" label="Email" input_type="email" format="email" required />
</DynamicForm.form>

Lifecycle hooks

The on_submit attribute mirrors phx-submit: it runs on every submit — valid or not — so expensive checks (like the uniqueness lookup below) batch with the built-in errors into one complete list, rendered inline on the form.

See the Lifecycle guide for more information on on_submit, on_change, and on_success lifecycle hooks.

<DynamicForm.form id="example-form" on_submit={&Contacts.verify/1}>
<:field type="text" name="name" label="Name" required />
<:field type="text" name="email" label="Email" input_type="email" format="email" required />
</DynamicForm.form>
def verify(payload) do
if email_taken?(payload.data[:email]) do
DynamicForm.Payload.add_error(payload, :email, "has already been taken")
else
payload
end
end

Validation and visibility

Layer in additional validation attrs and conditional visibility — the details field only appears when the subject is support, and hidden required fields are excluded from validation automatically:

<DynamicForm.form id="example-form" on_submit={&Contacts.verify/1}>
<:field type="text" name="name" label="Name" min_length={2} required />
<:field type="text" name="email" label="Email" input_type="email" format="email" required />
<:field type="dropdown" name="subject" label="Subject" required options={[{"Support", "support"}, {"Sales", "sales"}]} />
<:field type="comment" name="details" label="Support Details" visible_if="{subject} = 'support'" />
<:field type="rating" name="satisfaction" label="Satisfaction" rate_min={1} rate_max={5} />
</DynamicForm.form>

Styling and custom fields

The library uses a version of the CoreComponents module that's generated by new Phoenix projects.

It can be configured to use your project's custom components. Either its version of CoreComponents or a custom module. It can be configured globally in the config/config.ex file or per-form using the components attribute.

When using a custom component module, the library is smart enough to fall back to using the built-in version that ships with the library if a component is not defined in the custom module.

Using a custom module is the preferred way to add custom fields as well as change the styling of the forms. There is also the ability to define custom syntax using the slot body.

See the Styling guide for detailed information on custom inputs and styling.

<DynamicForm.form id="example-form" on_submit={&Contacts.verify/1} components={MyAppWeb.CoreComponents}>
<:field type="text" name="name" label="Name" min_length={2} required />
<:field type="text" name="email" label="Email" input_type="email" format="email" required />
<:field type="dropdown" name="subject" label="Subject" required options={[{"Support", "support"}, {"Sales", "sales"}]} />
<:field type="comment" name="details" label="Support Details" visible_if="{subject} = 'support'" />
<:field :let={field} type="rating" name="rating" label="Rating">
<input type="range" min="1" max="5" step="1" name={field.name} id={field.id} value={field.value || 0} />
</:field>
</DynamicForm.form>

Grouping fields

Group fields into panels, and take over rendering where you need to — here a custom range control via a slot body, while the library still owns the label, errors, and changeset validation:

<DynamicForm.form id="example-form" on_submit={&Contacts.verify/1} components={MyAppWeb.CoreComponents}>
<:field type="text" name="name" label="Name" min_length={2} required />
<:field type="text" name="email" label="Email" input_type="email" format="email" required />
<:field type="dropdown" name="subject" label="Subject" required options={[{"Support", "support"}, {"Sales", "sales"}]} />
<:field type="comment" name="details" label="Support Details" visible_if="{subject} = 'support'" />
<:field :let={field} type="rating" name="rating" label="Rating">
<input type="range" min="1" max="5" step="1" name={field.name} id={field.id} value={field.value || 0} />
</:field>
<:field type="boolean" name="ship" label="Ship to a different address?" />
<:group name="address" title="Shipping Address" visible_if="{ship} = true" />
<:field group="address" type="text" name="street" label="Street" required />
<:field group="address" type="text" name="city" label="City" required />
</DynamicForm.form>

Nested forms

Use nested forms to allow users to add multiple records in the same form

The user can add and remove entries. Each entry is validated with its own child changeset, and the submitted value arrives as a list of maps (%{name: "...", addresses: [%{street: "...", city: "..."}, ...]}):

See the Nested forms guide for entry seeding, min/max entry counts, and per-entry validation.

<DynamicForm.form id="example-form" on_submit={&Contacts.verify/1} components={MyAppWeb.CoreComponents}>
<:field type="text" name="name" label="Name" min_length={2} required />
<:field type="text" name="email" label="Email" input_type="email" format="email" required />
<:field type="dropdown" name="subject" label="Subject" required options={[{"Support", "support"}, {"Sales", "sales"}]} />
<:field type="comment" name="details" label="Support Details" visible_if="{subject} = 'support'" />
<:field :let={field} type="rating" name="rating" label="Rating">
<input type="range" min="1" max="5" step="1" name={field.name} id={field.id} value={field.value || 0} />
</:field>
<:nested name="addresses" title="Addresses" entries={1} add_text="Add address" />
<:field nested="addresses" type="text" name="street" label="Street" required />
<:field nested="addresses" type="text" name="city" label="City" required />
</DynamicForm.form>

Define forms in data

Or define the same form as data. SurveyJS-compatible JSON passes straight in via the json attribute (<DynamicForm.form id="example-form" json={@json} />), or decode it at the edge — from JSON, a map, or built as structs — and pass the instance to the same component:

{
"title": "Example Form",
"elements": [
{"type": "text", "name": "name", "inputType": "text"},
{"type": "text", "name": "email", "inputType": "email"}
]
}
<DynamicForm.form id="example-form" json={@json} />

Render only

The library handles events and validations by default. These can be turned off if you prefer to just use the library as a renderer and to instead handle the actions yourself using the standard handle_event handlers in the LiveView.

Use the form, phx_change, phx_submit and render_only attributes to manage the lifecycle in the LiveView:

<DynamicForm.form id="example-form" form={@form} phx_change="validate" phx_submit="save" render_only>
<:field type="text" name="name" label="Name" required />
<:field type="text" name="email" label="Email" input_type="email" format="email" required />
</DynamicForm.form>
def mount(_params, _session, socket) do
{:ok, assign(socket, :form, to_form(Contacts.changeset(%{}), as: "contact"))}
end
def handle_event("validate", %{"contact" => _params}, socket) do
{:noreply, socket}
end
def handle_event("save", %{"contact" => _params}, socket) do
{:noreply, socket}
end

Demo app

The /examples directory contains a full Phoenix demo app exercising every feature — declarative and data definitions, every question type, conditional logic, panels, custom markup, file uploads, and submission handling:

git clone https://github.com/chrislaskey/dynamic_form.git
cd dynamic_form/examples/demo
mix setup && iex -S mix phx.server

The demo is generated: examples/regenerate.sh rebuilds it from a pinned phx.new release, then applies the version-controlled examples/overlay/ directory on top. The interesting demo code — LiveViews, form definitions, layout tweaks — lives in the overlay; edit there, copy over the demo (cp -R overlay/. demo/), and never edit generated files directly.

Additional documentation

Installation

DynamicForm is not yet published to Hex. Add it as a path or git dependency in mix.exs:

def deps do
[
{:dynamic_form, "~> 0.17"}
]
end

Tailwind CSS

The built-in components use the same markup as phx.new 1.8 generates: Tailwind utility classes plus daisyUI component classes (input, select, checkbox, radio, btn, fieldset, ...).

Phoenix 1.8+ apps vendor daisyUI by default, so the only step is pointing Tailwind at DynamicForm's source in assets/css/app.css:

@source "../../deps/dynamic_form/lib";

Apps without daisyUI (Phoenix ≤ 1.7, or apps that removed it) have two options: vendor daisyUI the way phx.new 1.8 does (see the comments in a freshly generated assets/css/app.css), or point the library at your own components with the components attribute/config — your module's markup then replaces the built-ins entirely. See the Styling guide for every customization level.

File uploads (optional)

Direct-to-cloud file uploads (type="file") require a presigner module and a JavaScript uploader registered in assets/js/app.js — see Usage: File uploads.

License

MIT — see LICENSE.md.