A nested form is a repeating child form — a list of sub-records the user can add and remove, like a contact with multiple addresses:
%{name: "Ada", addresses: [%{street: "110 Main St", city: "Portland"}, ...]}DynamicForm supports nested forms in both definition modes:
- Data mode — the SurveyJS
paneldynamicquestion type, decoded from JSON/maps. - Declarative mode —
<:nested>slot declarations inside<DynamicForm.form>.
Both converge on the same Instance.Question{type: "paneldynamic"} struct,
so validation, rendering, and submission behave identically.
How it works
Every entry is validated with its own dynamic Ecto changeset built from the
template — the same schemaless-changeset machinery as the top-level form,
applied recursively (DynamicForm.NestedForms). There are no Ecto
relations, embedded schemas, or runtime-generated modules involved: entry
changesets are a pure function of the question and the form's current
params, so the validation pass and the renderer always see identical
errors. isRequired, validators, and conditional expressions all apply
per entry, and errors render inline inside the entry that caused them.
The rendered entries are namespaced sub-forms
(dynamic_form[addresses][0][street]), with add/remove buttons wired to
the managed component automatically. The submitted value is a list of maps
keyed by the template questions' names — matching SurveyJS's data shape.
Data mode (SurveyJS paneldynamic)
{
"type": "paneldynamic",
"name": "addresses",
"title": "Addresses",
"templateTitle": "Address {panelIndex}",
"templateElements": [
{"type": "dropdown", "name": "kind", "choices": ["Home", "Work", "Other"], "isRequired": true},
{"type": "text", "name": "street", "title": "Street", "isRequired": true},
{"type": "text", "name": "label", "title": "Label", "visibleIf": "{panel.kind} = 'Other'"}
],
"panelCount": 1,
"minPanelCount": 1,
"maxPanelCount": 4,
"addPanelText": "Add another address",
"removePanelText": "Remove address",
"confirmDelete": true,
"keyName": "kind",
"keyDuplicationError": "You already have an address of this type."
}Supported properties: templateElements (alias questions),
templateTitle (with {panelIndex} interpolating the 1-based entry
number), panelCount, minPanelCount, maxPanelCount, allowAddPanel,
allowRemovePanel, addPanelText (alias panelAddText),
removePanelText (alias panelRemoveText), noEntriesText,
confirmDelete, confirmDeleteText, keyName, keyDuplicationError,
and defaultPanelValue.
Declarative mode (<:nested> slots)
Because named slots can't nest in HEEx, the declarative syntax is flat and
reference-based — the same pattern as <:group>:
<DynamicForm.form id="contact-form">
<:field type="text" name="name" label="Full name" required />
<:nested name="addresses" title="Addresses" entry_title="Address {panelIndex}"
entries={1} min_entries={1} max_entries={4}
add_text="Add another address" key="kind" />
<:field nested="addresses" type="dropdown" name="kind" label="Type"
options={["Home", "Work", "Other"]} required />
<:field nested="addresses" type="text" name="street" label="Street" required />
<:field nested="addresses" type="text" name="label" label="Label"
visible_if="{panel.kind} = 'Other'" />
</DynamicForm.form><:nested> attributes map to the SurveyJS question properties:
entry_title → templateTitle, entries → panelCount (entries seeded
on a fresh form), min_entries/max_entries →
minPanelCount/maxPanelCount, add_text/remove_text,
no_entries_text, confirm_delete/confirm_text, key/key_error,
default (a list of entry maps — edit-style initial value), and
default_entry (values seeded into each newly added entry). required,
visible_if, and enable_if work as on fields.
The scope model: nested and group are orthogonal
nesteddeclares an entry's data scope — where its value lives. Absent means the top-level form;nested="addresses"means inside each entry of theaddresseslist.groupdeclares visual grouping within whatever scope the entry is in. Groups never change the data shape.
The two combine. A group inside a nested form declares the scope on both
ends — on the <:group> declaration and on every member field — and any
mismatch raises at definition time (deliberate double-entry bookkeeping: a
forgotten attribute becomes an error instead of a silent data-shape
change):
<:nested name="addresses" title="Addresses" min_entries={1} />
<:group name="geo" title="Location" nested="addresses" />
<:field nested="addresses" type="dropdown" name="kind" options={["Home", "Work"]} />
<:field nested="addresses" group="geo" type="text" name="street" />
<:field nested="addresses" group="geo" type="text" name="city" />Composition works in every direction except group-in-group (an existing declarative-mode limitation):
| Composition | Spelling |
|---|---|
| Field in a nested form | <:field nested="addresses"> |
| Group inside a nested form | <:group nested="addresses"> + members declare both |
| Nested form inside a group | <:nested group="section"> |
| Nested form inside a nested form | <:nested name="phones" nested="contacts"> |
Naming: unique per scope
Field and <:nested> names are data keys, unique per scope — the
top-level form is one scope, each nested form's template is another. So
names can mirror your schema (user.name alongside addresses[].name):
<:field type="text" name="name" label="Your name" />
<:field nested="addresses" type="text" name="name" label="Address nickname" /><:group> and <:nested> declaration names are reference targets for
the flat group=/nested= attributes, so declarations are globally
unique per form.
Custom controls per entry
Slot bodies work inside templates. The body receives the per-entry
Phoenix.HTML.FormField — the same closure renders once per entry with
each entry's name, value, and errors, while the library keeps the label,
error display, and changeset validation:
<:field :let={field} nested="milestones" type="text" name="effort"
input_type="number" label="Effort (days)">
<input type="range" min="1" max="30"
name={field.name} id={field.id} value={field.value || 5} />
</:field>The field is scoped to its entry — field.form[:title].value reads a
sibling in the same entry. To read outside the entry, use
DynamicForm.form_data/1, which returns the whole form's current values
whatever scope the body is in, so a control in one nested form can build
itself from another's entries. See
Reading the whole form from a body.
Conditional expressions and shadowing
Inside a template, expressions resolve innermost-first:
{panel.field}explicitly references a sibling value in the same entry (SurveyJS's scoping prefix).- A plain
{field}reference resolves against the entry first, then the enclosing form — so when a template field shadows a top-level name, the entry's own value wins. Use distinct names if an expression inside a template needs the outer value.
visibleIf, requiredIf, and enableIf on template questions are
evaluated per entry, and hidden required questions are excluded from
validation as usual.
Validation
Beyond per-entry validation of the template questions:
isRequiredon the nested question requires at least one entry.minPanelCount/maxPanelCount(min_entries/max_entries) validate the entry count on submit; the add/remove buttons also hide at the limits.keyName(key) enforces that one template field's value is unique across entries, erroring on each duplicate withkeyDuplicationError(key_error).- An invalid entry marks the parent changeset invalid; the parent-level
error carries a
validation: :paneldynamicmarker and is suppressed from inline display (each entry shows its own errors).
Entry lifecycle
- A fresh form seeds
panelCount/entriesentries (at leastminPanelCount/min_entries); each is seeded from the template questions'defaultValues merged withdefaultPanelValue/default_entry. - In edit mode, pass the list under the question's name in
data:data={%{"addresses" => [%{"street" => "110 Main St"}]}}. payload.dataon submission carries the applied nested value — a list of atom-keyed maps with cast values, entries omitting unanswered questions (matching SurveyJS).
Entry ids
Entries are otherwise positional, so a value elsewhere in the form that
references one has nothing durable to point at: a member field changes when
the user edits it, and an index changes when entries are added or removed.
Every entry therefore carries a dynamic_form_id, seeded once and
round-tripped through a hidden input:
- an entry loaded from
datawith anidadopts that id, so references match the record the app already knows about; - an entry the user adds this session gets a generated one;
- an entry that already has a
dynamic_form_idkeeps it.
It arrives in payload.data alongside the entry's own fields:
%{staff: [%{dynamic_form_id: "42", name: "Ada"}]}The id is only stable across sessions if you persist it and pass it back
in data. Without that, ids regenerate on reload and anything referencing
them silently points at nothing. Where entries map to stored records with
their own ids, that happens for free — the id is copied, not generated.
Set generate_ids={false} ("generateIds": false in data mode) on a nested
form that doesn't need identity, and no field is added to its entries.
Styling the entry container
Each repeating entry renders inside the nested_entry/1 component — a
bordered card by default. Define nested_entry/1 on your
components module to restyle it; it receives index, the
question name, and the entry contents (title, remove button, child
fields) as inner_block:
def nested_entry(assigns) do
~H"""
<div class="mt-3 border-l-4 border-indigo-300 bg-indigo-50/50 p-4">
{render_slot(@inner_block)}
</div>
"""
endStandalone renderer events
DynamicForm.form/1 and DynamicForm.RendererLive handle adding and
removing entries automatically. When driving DynamicForm.Renderer
manually, handle the events in your own LiveView:
"add_nested_entry"with%{"path" => path}"remove_nested_entry"with%{"path" => path, "index" => index}
path is the dot-separated location of the nested question in the params
tree — "addresses" at the top level, "contacts.0.phones" inside
another nested form. DynamicForm.NestedForms.find_question/2 resolves a
path to its question, and DynamicForm.NestedForms.new_entry/1 builds a
seeded entry.
Limitations
- File upload questions are not supported inside templates (declarative mode raises; data mode leaves the upload unconfigured).
- SurveyJS display modes other than the default list (
carousel,tab) are not implemented, nor aretemplateVisibleIfor{parentPanel.*}/{prevPanel.*}/{prevRow.*}references. - Matrix types (
matrixdynamic) are not yet implemented — they share this feature's data shape and validation machinery and are a natural follow-up. - Declarative mode: group-in-group remains unsupported, and
<:nested>declaration names are globally unique (see Naming above).