Dynamic Lists Without the State Juggling
Forms that let users add and remove rows — line items, addresses, contacts, attendees — are deceptively hard to build well. State management, validation, default values, and index tracking all need to stay in sync. Most implementations drift toward hand-rolled state that bypasses RHF entirely.
This guide shows how Formwright handles repeating field groups, from a simple one-liner schema to a fully custom grid layout.
What we're building
An invoice form with a dynamic line items table. Each row has: item name, quantity, unit price, and a taxable flag. Users can add rows, remove rows, and submit the whole list.
What it usually looks like (before)
Manual state, no RHF integration, no schema validation.
import { useState } from "react";
type LineItem = { name: string; qty: number; price: number; taxable: boolean };
const EMPTY_ITEM: LineItem = { name: "", qty: 1, price: 0, taxable: false };
export function InvoiceForm() {
const [items, setItems] = useState<LineItem[]>([EMPTY_ITEM]);
function add() {
setItems((prev) => [...prev, { ...EMPTY_ITEM }]);
}
function remove(index: number) {
setItems((prev) => prev.filter((_, i) => i !== index));
}
function update(index: number, key: keyof LineItem, value: unknown) {
setItems((prev) =>
prev.map((item, i) => (i === index ? { ...item, [key]: value } : item))
);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// items is separate from any RHF state — have to merge manually
console.log(items);
}
return (
<form onSubmit={handleSubmit}>
{items.map((item, i) => (
<div key={i}> {/* ← unstable key = broken focus on re-render */}
<input
value={item.name}
onChange={(e) => update(i, "name", e.target.value)}
placeholder="Item name"
/>
<input
type="number"
value={item.qty}
onChange={(e) => update(i, "qty", Number(e.target.value))}
/>
<input
type="number"
value={item.price}
onChange={(e) => update(i, "price", Number(e.target.value))}
/>
<input
type="checkbox"
checked={item.taxable}
onChange={(e) => update(i, "taxable", e.target.checked)}
/>
<button type="button" onClick={() => remove(i)}>Remove</button>
</div>
))}
<button type="button" onClick={add}>Add item</button>
<button type="submit">Submit</button>
</form>
);
}
No validation. An empty name or negative qty submits fine.
Unstable keys. Using array index as key breaks focus when items are removed mid-list.
Disconnected from RHF. If the rest of your form uses RHF, line items live in separate state that must be merged at submit time. Easy to forget. Easy to get wrong.
No minimum. Nothing stops the user from removing all rows and submitting an empty list.
With Formwright (after)
Declare the array field in the schema. The runtime handles stable IDs, default values, and validation.
import { buildForm, defineForm, field, layout } from "formwright/schema";
const lineItems = field.objectArray("lineItems", {
label: "Line items",
item: {
kind: "object",
fields: {
name: field.textItem({ label: "Item", default: "" }),
qty: field.numberItem({ label: "Qty" }),
price: field.numberItem({ label: "Unit price" }),
taxable: field.booleanItem({ label: "Taxable" }),
},
},
minItems: 1, // at least one row required
maxItems: 20,
});
const notes = field.textarea("notes", { label: "Notes" });
export const invoiceForm = buildForm({
form: defineForm({ id: "invoice" }),
fields: [lineItems, notes],
layout: layout.stack("root", [
layout.field(lineItems),
layout.field(notes),
]),
});
The auto-layout renderer handles add/remove. Each item gets a stable ID from the engine — no index keys, no broken focus.
import { FormRuntimeProvider, FormRuntimeRoot, useCreateFormRuntime } from "formwright/react";
import { registerBasicPlugins } from "formwright/plugins";
import { invoiceForm } from "./invoice-form";
export function InvoicePage() {
const runtime = useCreateFormRuntime({
form: invoiceForm,
plugins: registerBasicPlugins(),
});
return (
<FormRuntimeProvider runtime={runtime}>
<FormRuntimeRoot rootLayoutId="root" />
</FormRuntimeProvider>
);
}
Custom layout with FormArray
The default renderer stacks fields vertically. For a table layout, use FormArray compound components to control the markup.
import { FormArray, FormField } from "formwright/react";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
export function LineItemsField() {
return (
<FormArray.Root path="lineItems">
{/* Column headers */}
<div className="grid grid-cols-[1fr_80px_120px_80px_40px] gap-2 px-1 mb-1 text-sm font-medium text-muted-foreground">
<span>Item</span>
<span>Qty</span>
<span>Unit price</span>
<span>Taxable</span>
<span />
</div>
<FormArray.Items>
{(item, index) => (
<FormArray.Item key={item.id} index={index}>
<div className="grid grid-cols-[1fr_80px_120px_80px_40px] gap-2 items-start mb-2">
<FormField.Root path={`lineItems.${index}.name`}>
<FormField.Control>
{({ value, onChange, error }) => (
<Input
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
aria-invalid={Boolean(error)}
/>
)}
</FormField.Control>
<FormField.Error />
</FormField.Root>
<FormField.Root path={`lineItems.${index}.qty`}>
<FormField.Control>
{({ value, onChange }) => (
<Input
type="number"
value={value == null ? "" : String(value)}
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
/>
)}
</FormField.Control>
</FormField.Root>
<FormField.Root path={`lineItems.${index}.price`}>
<FormField.Control>
{({ value, onChange }) => (
<Input
type="number"
value={value == null ? "" : String(value)}
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
/>
)}
</FormField.Control>
</FormField.Root>
<FormField.Root path={`lineItems.${index}.taxable`}>
<FormField.Control>
{({ value, onChange }) => (
<Checkbox
checked={Boolean(value)}
onCheckedChange={(v) => onChange(Boolean(v))}
/>
)}
</FormField.Control>
</FormField.Root>
<FormArray.Remove index={index}>
<Button variant="ghost" size="icon" type="button">
<Trash2 className="h-4 w-4" />
</Button>
</FormArray.Remove>
</div>
</FormArray.Item>
)}
</FormArray.Items>
<FormArray.Add>
<Button variant="outline" size="sm" type="button" className="mt-2">
+ Add line item
</Button>
</FormArray.Add>
</FormArray.Root>
);
}
item.id is a stable engine-generated ID — safe to use as the key prop. No index keys, no broken focus on remove.
Use LineItemsField inside your FormRuntimeProvider:
<FormRuntimeProvider runtime={runtime}>
<LineItemsField />
<FormField.Root path="notes">
<FormField.Label />
<FormField.Control />
</FormField.Root>
</FormRuntimeProvider>
Direct hook access
useFormArray gives you the array state and mutation methods without the compound components:
const { items, append, remove, disabled } = useFormArray("lineItems");
append() with no argument creates a new item using the defaults from the schema. Pass a value to pre-fill:
append({ name: "Consulting", qty: 1, price: 150, taxable: false });
Useful for duplicating rows, importing from a template, or seeding items from an API response.
What changes and what stays the same
What changes:
- Array state moves from
useStateinto the schema and RHF (via the runtime) - Stable item IDs are generated by the engine — no index key bugs
- Validation (min/max items, required fields per row) is declared in the schema
- Default values per item field come from schema, not scattered
EMPTY_ITEMconstants
What stays the same:
- Submit payload is a flat RHF object —
lineItemsis a normal array invalues - You still compose field inputs manually with
FormField.Controlif you need custom markup - Runtime state (
disabled,visible) applies per-item via the dot-path field reference
See also
- React API —
FormArray,useFormArray,FormField - Schema API —
field.array,field.objectArray, item helpers - Bring Your Own Components — wire array fields to your design system