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.
| Approach | Use it when |
|---|---|
| Steps with sections | The outer level can be its own screen |
| Sibling section | Everything stays on one page. The default |
| Subheading field | You only need a heading inside a section |
| One-item group | The 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.
{
"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.
{
"$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 } }
]
}
]
}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
.subsectionwith 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.
{
"$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 }
}
]
}
]
}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 withcustom-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 withThis field is required. - The heading does not wrap the fields below it. Each field needs its own
show, asstreetandcitydo 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.
{
"$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: 1starts the group with one item. Without it, the group starts empty.required: truerejects an empty group.minLength: 1alone does not.- At
maxLength: 1the renderer hides Add. Withrequiredit 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:
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.
Related
- Sections: properties, rules and custom markup
- Repeatable groups: item count, conditions and error paths
- Field types: custom types and
props - Multi-step forms: step conditions and navigation