implement
Field arrays

Field arrays

Lists of fields that can be added to, reordered and removed from.

An array in the schema is a field array: a list whose items are fields in their own right.

Shopping list
import { Div, ForEach, If, signal, type Readable } from "@implementjs/core";
import { createForm, Form, useField, useFieldArray } from "@implementjs/formish";
import * as v from "valibot";
import { Button } from "@/lib/components/ui/button";
import {
	Field,
	FieldDescription,
	FieldError,
	FieldGroup,
	FieldLabel,
	FieldLegend,
	FieldSet,
} from "@/lib/components/ui/field";
import { Input } from "@/lib/components/ui/input";

const ShoppingListSchema = v.object({
	items: v.pipe(
		v.array(
			v.object({
				label: v.pipe(v.string(), v.minLength(1, "Every item needs a name")),
			}),
		),
		v.minLength(1, "Add at least one item"),
	),
});

export default function FieldArrayDemo() {
	const form = createForm({
		schema: ShoppingListSchema,
		initialInput: { items: [{ label: "Coffee" }, { label: "Oat milk" }] },
	});
	const items = useFieldArray(form, { path: ["items"] });
	const saved = signal("");

	// one row: the path is a readable, so a row that moves follows its item
	function ItemField(_id: Readable<string>, index: Readable<number>) {
		const field = useField(form, { path: items.itemPath(index, "label") });
		// set only while the field has an error: `data-invalid` styles the field,
		// `aria-invalid` announces it
		const invalid = field.errors.bind((errors) => (errors ? "true" : undefined));

		return Field(
			{ "data-invalid": invalid },
			FieldLabel({ class: "sr-only", for: field.name }, "Item"),
			Div(
				{ class: "flex items-center gap-2" },
				Input({ ...field.props, id: field.name, value: field.input, "aria-invalid": invalid }),
				Button(
					{
						variant: "outline",
						size: "icon",
						"aria-label": "Move up",
						disabled: index.bind((current) => current === 0),
						onClick: () => items.move({ from: index.get(), to: index.get() - 1 }),
					},
					"↑",
				),
				Button(
					{
						variant: "outline",
						size: "icon",
						"aria-label": "Remove",
						onClick: () => items.remove({ at: index.get() }),
					},
					"✕",
				),
			),
			If(field.error).Then(FieldError(field.error)),
		);
	}

	return Form(
		{
			of: form,
			onSubmit: (output) => saved.set(output.items.map((item) => item.label).join(", ")),
			class: "w-full max-w-sm",
		},
		FieldSet(
			FieldLegend({ variant: "label" }, "Shopping list"),
			FieldGroup(
				{ class: "gap-4" },
				ForEach(items.items, (id) => id, ItemField),
				If(items.error).Then(FieldError(items.error)),
				Div(
					{ class: "flex items-center gap-2" },
					Button(
						{ variant: "outline", onClick: () => items.insert({ initialInput: { label: "" } }) },
						"Add item",
					),
					Button({ type: "submit" }, "Save"),
				),
				If(saved).Then(FieldDescription(saved.bind((list) => `Saved: ${list}`))),
			),
		),
	);
}
const ShoppingListSchema = v.object({
	items: v.array(
		v.object({ label: v.pipe(v.string(), v.minLength(1, "Every item needs a name")) }),
	),
});

const items = useFieldArray(form, { path: ["items"] });

useFieldArray accepts only paths that lead to an array, so path: ["items"] autocompletes and path: ["email"] does not compile.

Rendering the rows

items.items is a readable list of ids — one per item, in order. Render by id with ForEach so a row keeps its DOM node when the list changes:

ForEach(
	items.items,
	(id) => id,
	(_id, index) =>
		Field({ of: form, path: items.itemPath(index, "label") }, (field) =>
			Input({ ...field.props, value: field.input }),
		),
);

itemPath(index, ...rest) builds the path of a field inside the item at index["items", 2, "label"] — and because index is a readable, so is the path. That is the point: when a row moves, its path follows it, and the same input goes on editing the same item.

The ids are the reason rows survive reordering. They stay with their item across inserts, moves and removals, so ForEach moves the existing node rather than rebuilding it — the caret stays where it was.

Adding, removing, reordering

The array store carries the methods, each of which also exists as a standalone function taking a path:

items.insert({ initialInput: { label: "" } }); // append
items.insert({ at: 0, initialInput: { label: "" } }); // insert
items.remove({ at: 2 });
items.move({ from: 3, to: 0 });
items.swap({ at: 0, and: 1 });
items.replace({ at: 1, initialInput: { label: "Tea" } });
import { insert, remove } from "@implementjs/formish";

insert(form, { path: ["items"], initialInput: { label: "" } });
remove(form, { path: ["items"], at: 2 });

Each of them moves the items' state along with the items: a row's errors, its touched and edited state, and the ids of any array nested inside it. Remove the second of three rows and the third row's error is still the third row's error, now on the second.

The list itself

The array field has state of its own, rolled up from its items:

items.errors; // errors reported about the list, e.g. a minLength around the array
items.isDirty; // an item was added, removed, moved, or one of them changed
items.isTouched;
items.isValid; // nothing in the list has errors

A v.minLength(1, "Add at least one item") around the array reports on the list, so render items.error next to the list rather than inside a row.

An array field with no items yet validates as an empty list, not as a missing one — so an empty list is what your schema sees, and a minLength on it is what reports the problem.

The FieldArray component

FieldArray is useFieldArray with the markup kept alongside it, the same way Field is:

FieldArray({ of: form, path: ["items"] }, (items) =>
	Div(
		ForEach(
			items.items,
			(id) => id,
			(_id, index) =>
				Field({ of: form, path: items.itemPath(index, "label") }, (field) =>
					Input({ ...field.props, value: field.input }),
				),
		),
		Button({ onClick: () => items.insert({ initialInput: { label: "" } }) }, "Add"),
	),
);

Nested arrays

Arrays nest as deep as the schema does. Address the inner one through the outer one's item path:

const tags = useFieldArray(form, { path: items.itemPath(index, "tags") });

Or, when the index is known, spell it out: path: ["items", 0, "tags"].