Skip to content

How do I put a section inside a section? ​

A section cannot contain a section. That is an invalid-nesting error. Nesting is left out on purpose because few forms need it. If you do, tell us in GitHub issues.

ApproachUse it when
Steps with sectionsThe outer level can be its own screen
Sibling sectionEverything stays on one page. The default
Subheading fieldYou only need a heading inside a section
One-item groupThe API expects a nested object

Steps with sections ​

A step is the outer level and its sections are the inner one. A step show hides the whole level.

json
{
  "formatVersion": "1.1",
  "id": "checkout",
  "title": "Checkout",
  "submit": { "label": "Continue" },
  "steps": [
    {
      "id": "delivery",
      "title": "Delivery",
      "fields": [
        {
          "key": "delivery",
          "type": "radio",
          "label": "Delivery",
          "defaultValue": "courier",
          "options": [{ "value": "courier", "label": "Courier" }, { "value": "pickup", "label": "Pickup point" }]
        }
      ]
    },
    {
      "id": "shipping",
      "title": "Shipping",
      "show": [{ "field": "delivery", "eq": "courier" }],
      "fields": [
        {
          "key": "contact",
          "type": "section",
          "title": "Recipient",
          "fields": [
            { "key": "recipient", "type": "text", "label": "Name", "validation": { "required": true } },
            { "key": "phone", "type": "phone", "label": "Phone" }
          ]
        },
        {
          "key": "address",
          "type": "section",
          "title": "Address",
          "fields": [
            { "key": "street", "type": "text", "label": "Street", "validation": { "required": true } },
            { "key": "city", "type": "text", "label": "City", "validation": { "required": true } }
          ]
        }
      ]
    }
  ]
}

With Pickup point selected, the Shipping step is skipped and the form submits { "delivery": "pickup" }. See Multi-step forms.

The approaches below keep everything on one page.

Sibling section ​

Put the inner block in its own section next to the outer one. A custom section component finds it by key and renders it as part of the section above.

json
{
  "$schema": "https://formhaus.dev/schema/form-definition.json",
  "formatVersion": "1.1",
  "id": "shipping-flat",
  "title": "Checkout",
  "submit": { "label": "Continue" },
  "fields": [
    {
      "key": "delivery",
      "type": "radio",
      "label": "Delivery",
      "defaultValue": "courier",
      "options": [{ "value": "courier", "label": "Courier" }, { "value": "pickup", "label": "Pickup point" }]
    },
    {
      "key": "shipping",
      "type": "section",
      "title": "Shipping",
      "show": [{ "field": "delivery", "eq": "courier" }],
      "fields": [
        { "key": "recipient", "type": "text", "label": "Recipient", "validation": { "required": true } },
        { "key": "phone", "type": "phone", "label": "Phone" }
      ]
    },
    {
      "key": "shippingMore",
      "type": "section",
      "title": "Address",
      "show": [{ "field": "delivery", "eq": "courier" }],
      "fields": [
        { "key": "street", "type": "text", "label": "Street", "validation": { "required": true } },
        { "key": "city", "type": "text", "label": "City", "validation": { "required": true } }
      ]
    }
  ]
}
tsx
import { DefaultSection, FormRenderer, type SectionComponentProps } from '@formhaus/react';

function Section(props: SectionComponentProps) {
  if (!props.section.key.endsWith('More')) return <DefaultSection {...props} />;
  return (
    <div id={props.id} role="group" aria-label={props.title} aria-describedby={props.description ? props.descriptionId : undefined} className="subsection">
      {props.description && <p id={props.descriptionId}>{props.description}</p>}
      {props.children}
    </div>
  );
}

<FormRenderer definition={definition} components={{ section: Section }} onSubmit={send} />;
  • Style .subsection with an indent and no top margin, so it reads as part of Shipping.
  • Values stay flat: { "delivery": "courier", "recipient": "Ada", "street": "Main St 1", "city": "Oslo" }.
  • Validation and conditions work as in any section.
  • To show and hide both blocks together, give them the same show. The condition is written twice.
  • Only the key suffix links the two sections. Core does not know about it.

Subheading field ​

A custom field type with no value renders a heading between the fields of one section. It needs props, which requires @formhaus/core 0.15 and format 1.2.

json
{
  "$schema": "https://formhaus.dev/schema/form-definition.json",
  "formatVersion": "1.2",
  "id": "shipping-heading",
  "title": "Checkout",
  "submit": { "label": "Continue" },
  "fields": [
    {
      "key": "shipping",
      "type": "section",
      "title": "Shipping",
      "fields": [
        {
          "key": "delivery",
          "type": "radio",
          "label": "Delivery",
          "defaultValue": "courier",
          "options": [{ "value": "courier", "label": "Courier" }, { "value": "pickup", "label": "Pickup point" }]
        },
        { "key": "recipient", "type": "text", "label": "Recipient", "validation": { "required": true } },
        {
          "key": "addressHeading",
          "type": "x-subheading",
          "label": "Address",
          "props": { "level": 4 },
          "show": [{ "field": "delivery", "eq": "courier" }]
        },
        {
          "key": "street",
          "type": "text",
          "label": "Street",
          "show": [{ "field": "delivery", "eq": "courier" }],
          "validation": { "required": true }
        },
        {
          "key": "city",
          "type": "text",
          "label": "City",
          "show": [{ "field": "delivery", "eq": "courier" }],
          "validation": { "required": true }
        }
      ]
    }
  ]
}
tsx
import { FormRenderer, type FieldComponentProps } from '@formhaus/react';

const headings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const;

function Subheading({ field }: FieldComponentProps) {
  const Heading = headings[Number(field.props?.level) - 1] ?? 'h4';
  return <Heading className="subheading">{field.label}</Heading>;
}

<FormRenderer definition={definition} components={{ 'x-subheading': Subheading }} onSubmit={send} />;
  • A custom type name needs - or :. Without one, checkDefinition() warns with custom-type-name.
  • The field never gets a value, so it is not in the submitted values. validateSubmission() treats it as any custom field and keeps a value a client sends under its key. Drop that key on the server before you store the submission.
  • Leave out required. A required subheading always fails with This field is required.
  • The heading does not wrap the fields below it. Each field needs its own show, as street and city do here.

One-item group ​

A group inside a section, limited to one item, is a real nested container. The value becomes an array with one object.

json
{
  "$schema": "https://formhaus.dev/schema/form-definition.json",
  "formatVersion": "1.1",
  "id": "shipping-group",
  "title": "Checkout",
  "submit": { "label": "Continue" },
  "fields": [
    {
      "key": "delivery",
      "type": "radio",
      "label": "Delivery",
      "defaultValue": "courier",
      "options": [{ "value": "courier", "label": "Courier" }, { "value": "pickup", "label": "Pickup point" }]
    },
    {
      "key": "shipping",
      "type": "section",
      "title": "Shipping",
      "show": [{ "field": "delivery", "eq": "courier" }],
      "fields": [
        { "key": "recipient", "type": "text", "label": "Recipient", "validation": { "required": true } },
        {
          "key": "address",
          "type": "group",
          "label": "Address",
          "itemLabel": "Address",
          "defaultItems": 1,
          "validation": { "required": true, "maxLength": 1 },
          "fields": [
            { "key": "street", "type": "text", "label": "Street", "validation": { "required": true } },
            { "key": "city", "type": "text", "label": "City", "validation": { "required": true } }
          ]
        }
      ]
    }
  ]
}
  • defaultItems: 1 starts the group with one item. Without it, the group starts empty.
  • required: true rejects an empty group. minLength: 1 alone does not.
  • At maxLength: 1 the renderer hides Add. With required it hides Remove at one item.
  • Submitted values: { "delivery": "courier", "recipient": "Ada", "address": [{ "street": "Main St 1", "city": "Oslo" }] }.
  • Item errors use paths like address.0.street.

The default renderer draws the group as a fieldset with an item card, so "Address" shows twice. A group component renders plain markup:

tsx
import { DefaultGroup, FormRenderer, type GroupComponentProps } from '@formhaus/react';

function Group(props: GroupComponentProps) {
  if (props.group.validation?.maxLength !== 1) return <DefaultGroup {...props} />;
  const errorId = `${props.id}-error`;
  return (
    <div
      id={props.id}
      role="group"
      aria-label={props.label}
      aria-invalid={!!props.error || undefined}
      aria-describedby={props.error ? errorId : undefined}
    >
      <h4>{props.label}</h4>
      {props.error && <p id={errorId} role="alert">{props.error}</p>}
      {props.items.map((item) => (
        <div key={item.id} id={item.titleId} data-fh-group-item>{item.children}</div>
      ))}
      {props.canAdd && (
        <button type="button" onClick={props.add} disabled={props.disabled} data-fh-group-add>
          {props.addLabel}
        </button>
      )}
    </div>
  );
}

<FormRenderer definition={definition} components={{ group: Group }} onSubmit={send} />;

Keep the error and the Add button. With address: [] in initialValues the group starts empty, Submit shows the group error and Add creates the item.

Use a one-item group only when the API expects address as an object. Map address[0] to it before sending.