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.
Choices from another nested form
A choice field can build its options from another question's entries — SurveyJS calls this carry forward. Each program below picks which age groups it serves, and the choices track the age groups as the user adds, edits, and removes them:
<:nested name="age_groups" title="Age groups" min_entries={1} />
<:field nested="age_groups" type="dropdown" name="min" options={@months} required />
<:field nested="age_groups" type="dropdown" name="max" options={@months} required />
<:nested name="programs" title="Programs" />
<:field nested="programs" type="text" name="name" label="Name" required />
<:field nested="programs" type="checkbox" name="age_group_ids" label="Age groups"
choices_from="age_groups" choice_text="{min} - {max}" />choices_fromnames the source nested form.choice_textlabels each choice: a member field's name, or a template interpolating several —{panelIndex}gives the 1-based entry number. Required, since the alternative is labelling choices with opaque ids. An entry missing any interpolated field isn't offered as a choice, so a half-filled age group doesn't appear until it's complete.choice_valuenames the field supplying the stored value. It defaults to the entry'sdynamic_form_id, which is what makes a selection survive the user editing the entry it points at.no_choices_textreplaces the control entirely while the source has no entries to offer — the label stays, with your message under it, instead of an empty checkbox group:<:field nested="programs" type="checkbox" name="age_group_ids" label="Age groups" choices_from="age_groups" choice_text="{min} - {max}" no_choices_text="Add an age group above to assign it to this program." />
In data mode the same three are choicesFromQuestion,
choiceTextsFromQuestion, and choiceValuesFromQuestion.
Names resolve innermost-first: a source inside the same entry wins over a form-level one of the same name. That's what makes per-entry sources work — each team's lead chosen from that team's own members:
<:nested name="teams" title="Teams" />
<:nested nested="teams" name="members" title="Members" />
<:field nested="members" type="text" name="name" label="Name" required />
<:field nested="teams" type="dropdown" name="lead" label="Lead"
choices_from="members" choice_text="{name}" />What happens to stale references
A value the source no longer offers is cleared during validation, so it never
reaches payload.data. That covers a deleted entry, an option removed from
the definition, and emptying the source entirely — the alternative would hand
your application a payload that contradicts itself, with ids pointing at
entries the same submission deletes.
Clearing a single-value field (a dropdown) empties it, so a required error
can appear without the user touching anything. It also happens on load, so
reopening a record whose stored value is no longer offered clears it before
the user sees the form.
Nothing is cleared when the form can't observe what the source offers:
- the definition has no such question — a hand-edited JSON definition, since
declarative mode raises when
choices_fromnames nothing; - the submission carries no values for the source, as when
visible_ifhides it.
A source hidden by visible_if still supplies choices — its values are data
whether or not it renders.
Carrying forward from another choice field
The source can also be an ordinary choice question rather than a nested form.
Its own options carry forward, optionally narrowed by what the user selected
there with choices_mode — "all" (default), "selected", or
"unselected":
<:field type="checkbox" name="languages" label="Languages you speak"
options={[{"English", "en"}, {"Spanish", "es"}, {"Portuguese", "pt"}]} />
<:field type="dropdown" name="primary_language" label="Primary language"
choices_from="languages" choices_mode="selected" />choice_text and choice_value don't apply here — the source's options
already have a label and a value — and choices_mode doesn't apply to a
nested source, where every entry is a choice. Both raise if crossed. A value
the source stops offering is pruned, the same as a deleted entry.
Render-only mode
The parent owns the changeset in render-only
mode, so the entry-id seeding DynamicForm.form/1
does is the parent's job. Seed before building the changeset, or the default
value resolves to nothing:
questions = DynamicForm.Changeset.get_questions(instance.elements)
changeset =
params
|> DynamicForm.NestedForms.seed_entry_ids(questions)
|> then(&DynamicForm.Changeset.create_changeset(instance, &1))Naming a real member field with choice_value avoids the seeding entirely,
at the cost of the stability ids give you. Pruning is also the parent's:
it runs inside DynamicForm.Changeset.create_changeset/3, so a hand-rolled
changeset keeps stale references.
Persisting references
Selections are only meaningful as long as the ids they point at come back.
For a create flow, or one where the reference is consumed server-side and
never re-edited, generated ids are enough. If a saved record is reopened
for editing, the source entries must return with the same ids — persist
dynamic_form_id, or give the entries an id for the library to adopt.
Otherwise every reference is orphaned, and pruning deletes it on the first
change.
The section header
A nested form renders as a section: title and description on the left, the
add button opposite them on the right, then the entries below. Both are
ordinary strings, so they can be translated at the call site:
<:nested
name="age_groups"
title={gettext("Age groups")}
description={gettext("The age bands you sort children into.")}
entry_title={gettext("Group {panelIndex}")}
/>Omitting title falls back to the capitalized field name; setting it blank
(nil, false, or "") renders no heading at all, which is the way to get a
bare repeating list. The description is omitted when unset. The add button
hides at max_entries, which leaves the header as heading-only.
Styling the entry container
Each repeating entry renders inside the nested_entry/1 component — a
bordered card by default. Inside it, the entry is two columns: the entry
title (when entry_title is set) and the child fields on the left, and a
remove icon button on the right, top-aligned with the first field. The
right column collapses when the entry can't be removed, and the icon
button's tooltip and screen-reader label come from remove_text.
Define nested_entry/1 on your components module to restyle
the card; it receives index, the question name, and that two-column
frame 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).