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 paneldynamic question 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_titletemplateTitle, entriespanelCount (entries seeded on a fresh form), min_entries/max_entriesminPanelCount/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

  • nested declares an entry's data scope — where its value lives. Absent means the top-level form; nested="addresses" means inside each entry of the addresses list.
  • group declares 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):

CompositionSpelling
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:

  • isRequired on 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 with keyDuplicationError (key_error).
  • An invalid entry marks the parent changeset invalid; the parent-level error carries a validation: :paneldynamic marker and is suppressed from inline display (each entry shows its own errors).

Entry lifecycle

  • A fresh form seeds panelCount/entries entries (at least minPanelCount/min_entries); each is seeded from the template questions' defaultValues merged with defaultPanelValue/default_entry.
  • In edit mode, pass the list under the question's name in data: data={%{"addresses" => [%{"street" => "110 Main St"}]}}.
  • payload.data on 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 data with an id adopts 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_id keeps 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>
  """
end

Standalone 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 are templateVisibleIf or {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).