Examples
Every feature has a working, interactive demo in the live playground, the same app you get locally with npm run examples. Each tab below runs against the real library; the linked source is the complete, unabridged implementation.
| Demo | What it shows | Source |
|---|---|---|
| Basic + modes | The smallest real form, plus a live switcher for every validation mode (onChange/onBlur/onSubmit/onTouched/all) and reValidateMode | BasicForm.tsx |
| Bound fields | All four shipped components (TextField, NumberField, SelectField, CheckboxField) with their a11y wiring, validateOnMount, a useIsValid-gated submit, and focusFirstError | BoundFieldsForm.tsx |
| Form context | createFormContext: zero prop drilling with typed paths intact, all four flag hooks as a status bar, useFormError, and adoptValues as the post-save rebase | ContextForm.tsx |
| Hooks factory | createFormHooks(form, "invoice"): a module-singleton form baked into exported hooks (useInvoiceField and friends), with no provider and no form prop anywhere | HooksFactoryForm.tsx |
| Onboarding | A 26-field, five-section feature module: schema.ts → hooks.ts (createFormHooks) → one file per field, one per section, with path-scoped useIsDirty/useIsValid badges per section header | OnboardingForm/ |
| Nested + submit | Nested object paths and the full handleSubmit flow | NestedForm.tsx |
| Field array | useFieldArray basics: push, remove, reorder with stable row IDs | ArrayForm.tsx |
| Async | An async .refine username check with debounceMs, isValidating spinners, and race handling | AsyncForm.tsx |
| Wizard | Multi-step with useFormSteps: next() gates on the current step's fields, step headers goTo and land on the first invalid step | WizardForm.tsx |
| Conditional | Fields that appear based on other fields' values | ConditionalForm.tsx |
| Invoice | A larger, realistic form: line-item arrays with derived totals | InvoiceForm.tsx |
| Nested arrays | Arrays inside array rows, with IDs stable at both levels | NestedArraysForm.tsx |
| Server errors | The server error channel: errors that survive background revalidation and release when you edit the field | ServerErrorsForm.tsx |
| Autosave | Draft persistence with watchValues + localStorage, restored on mount, with dirtyFields() reporting what changed | AutosaveForm.tsx |
| Dependent | Cross-field reactions with watchValue | DependentFieldsForm.tsx |
| Optimistic | Optimistic UI with snapshot()/restore() rollback | OptimisticForm.tsx |
| File upload | File inputs and validating File values | FileUploadForm.tsx |
| Derived | Computed values via selectors, consistent by construction and never stored | DerivedFieldForm.tsx |
| Tags | A tag input over an array of primitives | TagInputForm.tsx |
| Perf | A 200-row stress test showing per-field re-render isolation | PerfBenchmarkForm.tsx |
Material UI
Six demos bind formstand to Material UI through one adapter file, muiAdapter.ts, which is the same adapter shape formstand-gen --ui mui emits. It turns a useField result into spreadable props for TextField, Select, Switch, and Slider. Numbers go through the library's exported useNumberInput, the text-preserving binding behind NumberField, since MUI's TextField is a text input (the stateless numberInputProps builder remains the right choice for native type="number" inputs). Nothing MUI-specific lives in the library; this is the adapter shape to copy for any third-party UI kit. All six run in the same live playground.
| Demo | What it shows | Source |
|---|---|---|
| MUI: Checkout | A Stepper wizard gating each step with validateFields, a "billing same as shipping" Switch that copies values, and a review step read from a selector | MuiCheckoutWizard.tsx |
| MUI: Job form | Autocomplete multiple over a string-array field, a salary Slider, an async email check with a CircularProgress adornment, and a server rejection via form.setError | MuiJobApplication.tsx |
| MUI: Invoice | useFieldArray rendered as a MUI Table with reorder/delete IconButtons, derived totals, an array-level error Alert, and a dirty-gated save that rebases with adoptValues | MuiInvoiceBuilder.tsx |
| MUI: Settings | Card-sectioned settings with a nullable bio (clearing the field round-trips to null via emptyValue), live dirtyFields() chips, and Save/Discard as adoptValues/reset() | MuiProfileSettings.tsx |
| MUI: Survey | Nested field arrays (sections → questions) with type-switched sub-editors and a root-level refine surfaced through useFormError | MuiSurveyBuilder.tsx |
| MUI: Onboarding | The Onboarding feature module over Material UI: Accordion sections with path-scoped dirty/valid chips | OnboardingForm/ |
shadcn/ui
Four demos bind formstand to shadcn/ui components. shadcn's Input and Textarea take native DOM events, so the library's own exported textInputProps and numberInputProps bind them with nothing extra, and the adapter file, shadcnAdapter.ts, only bridges the Radix dialect: Checkbox, Switch, Select, Slider, and RadioGroup take value-first callbacks (onCheckedChange, onValueChange) and signal "done editing" through close and commit events instead of blur. Errors surface as aria-invalid, which the components style themselves off. formstand-gen --ui shadcn generates forms against this pattern.
| Demo | What it shows | Source |
|---|---|---|
| shadcn: Signup | An async .refine username check isolated to its field's subschema, a cross-field password confirmation, and a must-accept Checkbox | ShadcnSignupForm.tsx |
| shadcn: Checkout | Radix Select and RadioGroup via onValueChange, a derived total from a selector, and a nullable gift note that round-trips to null via emptyValue | ShadcnCheckoutForm.tsx |
| shadcn: Settings | Switch and Slider bindings (validation waits for onValueCommit), live dirtyFields() badges, Save/Discard as adoptValues/reset() | ShadcnSettingsForm.tsx |
| shadcn: Team | useFieldArray rows (one component per row, so edits don't re-render siblings) with a cross-row duplicate-email superRefine | ShadcnTeamForm.tsx |
The playground bundles its own copies of the shadcn components (they're copy-in code by design); in your app, npx shadcn add button input label checkbox select … scaffolds them and the adapter works unchanged.
Browse the source inline
Every block below embeds the demo's source file at build time, so it cannot drift from what the playground runs. One transformation applies: the two lines of playground harness (the useDemoForm registration that powers the View state panel, and its import) are stripped, the same way the playground's own code panel strips them, so a listing copied into your project compiles as-is. Everything shown is plain formstand.
Basic + modes: BasicForm.tsx
import { useField, useForm, useFormSelector, type ValidationMode } from "formstand";
import { z } from "zod";
const schema = z.object({
name: z.string().min(2, "name must be at least 2 chars"),
email: z.email("must be a valid email"),
});
export const BasicForm = () => {
const form = useForm(schema, {
initialValues: { name: "", email: "" },
});
const mode = useFormSelector(form, (s) => s.mode);
const name = useField(form, "name");
const email = useField(form, "email");
const isSubmitting = useFormSelector(form, (s) => s.isSubmitting);
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.submit((data) => {
window.alert(`submit ok: ${JSON.stringify(data)}`);
});
}}
>
<div className="field">
<label htmlFor="mode-select">Validation mode</label>
<select
id="mode-select"
value={mode}
onChange={(e) => form.setMode(e.target.value as ValidationMode)}
>
<option value="onBlur">onBlur (default)</option>
<option value="onChange">onChange</option>
<option value="onSubmit">onSubmit</option>
</select>
</div>
<div className="field">
<label htmlFor="name">Name</label>
<input
id="name"
value={name.value ?? ""}
onChange={(e) => name.setValue(e.target.value)}
onBlur={name.onBlur}
/>
<span className="error">{name.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label htmlFor="email">Email</label>
<input
id="email"
value={email.value ?? ""}
onChange={(e) => email.setValue(e.target.value)}
onBlur={email.onBlur}
/>
<span className="error">{email.error?.[0] ?? " "}</span>
</div>
<div className="row">
<button className="primary" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
<button className="secondary" type="button" onClick={() => form.reset()}>
Reset
</button>
</div>
</form>
);
};Bound fields: BoundFieldsForm.tsx
import {
CheckboxField,
NumberField,
SelectField,
TextField,
focusFirstError,
useForm,
useIsValid,
} from "formstand";
import { z } from "zod";
const schema = z.object({
name: z.string().min(2, "min 2 chars"),
email: z.email("valid email required"),
seats: z.int().min(1, "at least one seat").max(20, "20 max"),
plan: z.enum(["starter", "pro", "enterprise"]).optional(),
terms: z.boolean().refine((v) => v, "you must accept the terms"),
});
export const BoundFieldsForm = () => {
const form = useForm(schema, {
// `plan` starts undefined — SelectField stays controlled and shows the
// placeholder option; `terms` false so useIsValid gates the button.
initialValues: { name: "", email: "", seats: 1, terms: false },
mode: "onBlur",
// Errors exist from the start so the submit button can be gated on
// useIsValid; error *display* still waits for touched via the components.
validateOnMount: true,
});
const isValid = useIsValid(form);
return (
<form
onSubmit={form.handleSubmit(
(data) => {
window.alert(`subscribed: ${JSON.stringify(data)}`);
},
(errors) => focusFirstError(errors),
)}
>
<p className="subtitle">
The four shipped bound components. Each wires up a label,{" "}
<code>name</code>, <code>aria-invalid</code>,{" "}
<code>aria-describedby</code> and a <code>role="alert"</code>{" "}
error — inspect the DOM. The submit button is gated on{" "}
<code>useIsValid</code> (enabled by <code>validateOnMount</code>).
</p>
<TextField form={form} path="name" label="Name" autoComplete="name" />
<TextField
form={form}
path="email"
label="Email"
type="email"
autoComplete="email"
/>
<NumberField form={form} path="seats" label="Seats" />
<SelectField
form={form}
path="plan"
label="Plan"
placeholder="Pick a plan…"
options={[
{ value: "starter", label: "Starter" },
{ value: "pro", label: "Pro" },
{ value: "enterprise", label: "Enterprise" },
]}
/>
<CheckboxField form={form} path="terms" label="I accept the terms" />
<button className="primary" type="submit" disabled={!isValid}>
Subscribe
</button>
</form>
);
};Form context: ContextForm.tsx
import {
TextField,
createFormContext,
useField,
useForm,
useFormError,
useIsDirty,
useIsSubmitting,
useIsValid,
useSubmitCount,
textInputProps,
} from "formstand";
import { z } from "zod";
const schema = z
.object({
displayName: z.string().min(2, "min 2 chars"),
tagline: z.string().max(60, "60 chars max"),
})
.refine((d) => d.displayName.toLowerCase() !== d.tagline.toLowerCase(), {
message: "tagline must differ from display name",
});
const { Provider, useFormContext } = createFormContext<typeof schema>();
// No form prop anywhere below — children reach the form through the typed
// context, so paths still autocomplete and typo'd ones fail to compile.
const DisplayNameField = () => {
const form = useFormContext();
return <TextField form={form} path="displayName" label="Display name" />;
};
const TaglineField = () => {
const form = useFormContext();
const tagline = useField(form, "tagline");
return (
<div className="field">
<label htmlFor="tagline-custom-input-via-textinputprops">Tagline (custom input via textInputProps)</label>
<input id="tagline-custom-input-via-textinputprops" {...textInputProps(tagline)} />
<span className="error">{tagline.error?.[0] ?? " "}</span>
</div>
);
};
const RootError = () => {
const form = useFormContext();
const rootError = useFormError(form);
return rootError !== undefined ? (
<p className="error">{rootError[0]}</p>
) : null;
};
const StatusBar = () => {
const form = useFormContext();
const dirty = useIsDirty(form);
const valid = useIsValid(form);
const submitting = useIsSubmitting(form);
const submits = useSubmitCount(form);
return (
<p className="subtitle">
{dirty ? "● Unsaved changes" : "No changes"} · currently{" "}
{valid ? "valid" : "invalid"} · {submits} submit attempt(s)
{submitting ? " · submitting…" : ""}
</p>
);
};
export const ContextForm = () => {
const form = useForm(schema, {
initialValues: { displayName: "", tagline: "" },
mode: "onBlur",
});
return (
<Provider form={form}>
<form
onSubmit={form.handleSubmit(async (data) => {
await new Promise((r) => setTimeout(r, 400));
window.alert(`saved: ${JSON.stringify(data)}`);
form.adoptValues(data);
})}
>
<p className="subtitle">
One <code>createFormContext</code> provider; every child below reads
the form from context — no prop drilling. The status bar is built
from the flag hooks (<code>useIsDirty</code> /{" "}
<code>useIsValid</code> / <code>useIsSubmitting</code> /{" "}
<code>useSubmitCount</code>); the schema-level refine surfaces via{" "}
<code>useFormError</code>.
</p>
<StatusBar />
<DisplayNameField />
<TaglineField />
<RootError />
<button className="primary" type="submit">
Save profile
</button>
</form>
</Provider>
);
};Hooks factory: HooksFactoryForm.tsx
import {
createForm,
createFormHooks,
numberInputProps,
textInputProps,
} from "formstand";
import { z } from "zod";
const lineItemSchema = z.object({
description: z.string().min(1, "required"),
quantity: z.int().positive("must be > 0"),
unitPrice: z.number().nonnegative("must be >= 0"),
});
const schema = z.object({
customer: z.string().min(1, "required"),
lineItems: z.array(lineItemSchema).min(1, "at least one item"),
});
// The form lives at module scope — one instance for the app's lifetime —
// and createFormHooks bakes it into the hooks it returns. No provider, no
// form prop: components below just import their domain's hooks. (Because
// the form never unmounts, edits here survive switching tabs — that's the
// singleton behaving as designed. Per-mount lifecycles want useForm +
// createFormContext instead.)
const invoiceForm = createForm(schema, {
initialValues: {
customer: "",
lineItems: [{ description: "", quantity: 1, unitPrice: 0 }],
},
mode: "onBlur",
});
export const {
useInvoiceField,
useInvoiceFieldArray,
useInvoiceSelector,
useInvoiceIsDirty,
useInvoiceIsSubmitting,
} = createFormHooks(invoiceForm, "invoice");
// No `form` prop — the row's hooks are already wired to the invoice form,
// and paths stay schema-typed (a typo'd path is a compile error).
const LineItemRow = ({
index,
onRemove,
}: Readonly<{ index: number; onRemove: () => void }>) => {
const description = useInvoiceField(`lineItems.${index}.description`);
const quantity = useInvoiceField(`lineItems.${index}.quantity`);
const unitPrice = useInvoiceField(`lineItems.${index}.unitPrice`);
return (
<div className="array-item" style={{ gridTemplateColumns: "2fr 1fr 1fr auto" }}>
<div>
<input
placeholder="description"
{...textInputProps(description)}
style={{ width: "100%" }}
/>
<div className="error" style={{ marginTop: 4 }}>
{description.error?.[0] ?? " "}
</div>
</div>
<div>
<input placeholder="qty" {...numberInputProps(quantity)} style={{ width: "100%" }} />
<div className="error" style={{ marginTop: 4 }}>
{quantity.error?.[0] ?? " "}
</div>
</div>
<div>
<input
placeholder="price"
{...numberInputProps(unitPrice)}
step="0.01"
style={{ width: "100%" }}
/>
<div className="error" style={{ marginTop: 4 }}>
{unitPrice.error?.[0] ?? " "}
</div>
</div>
<button className="secondary" type="button" onClick={onRemove}>
×
</button>
</div>
);
};
const Total = () => {
const total = useInvoiceSelector((s) =>
s.values.lineItems.reduce(
(acc, item) => acc + (item.quantity ?? 0) * (item.unitPrice ?? 0),
0,
),
);
return (
<div style={{ textAlign: "right", marginTop: 16, fontSize: 18 }}>
Total: <strong>${total.toFixed(2)}</strong>
</div>
);
};
export const HooksFactoryForm = () => {
const customer = useInvoiceField("customer");
const lineItems = useInvoiceFieldArray("lineItems");
const isDirty = useInvoiceIsDirty();
const isSubmitting = useInvoiceIsSubmitting();
return (
<form
onSubmit={(e) => {
e.preventDefault();
void invoiceForm.submit((data) => {
window.alert(`invoice: ${JSON.stringify(data, null, 2)}`);
});
}}
>
<p style={{ color: "#8b94a7", fontSize: 13, marginTop: 0 }}>
<code>createFormHooks(form, "invoice")</code> — the form is a module
singleton and every hook is pre-wired to it: no provider, no{" "}
<code>form</code> prop anywhere below. Edits survive switching tabs
(the form never unmounts); Reset puts it back.
</p>
<div className="field">
<label htmlFor="customer">Customer</label>
<input id="customer" {...textInputProps(customer)} />
<span className="error">{customer.error?.[0] ?? " "}</span>
</div>
{lineItems.fields.map((field, index) => (
<LineItemRow
key={field.id}
index={index}
onRemove={() => lineItems.remove(index)}
/>
))}
{lineItems.error ? (
<div className="error" style={{ marginBottom: 8 }}>
{lineItems.error[0]}
</div>
) : null}
<div className="row" style={{ marginTop: 8 }}>
<button
className="secondary"
type="button"
onClick={() =>
lineItems.push({ description: "", quantity: 1, unitPrice: 0 })
}
>
+ add line
</button>
<button
className="secondary"
type="button"
disabled={!isDirty}
onClick={() => invoiceForm.reset()}
>
Reset
</button>
</div>
<Total />
<div className="row" style={{ marginTop: 16 }}>
<button className="primary" type="submit" disabled={isSubmitting}>
Submit invoice
</button>
</div>
</form>
);
};Onboarding: the feature-module layout
One folder per form: schema.ts (zod + select options), types.ts, hooks.ts (createForm + createFormHooks), one file per field (props type + field hook + component), one file per section (props type + section hook built on the path-scoped flags + component). The key files:
hooks.ts: the module's pre-wired hooks
import { createForm, createFormHooks } from "formstand";
import { blankOnboardingValues, onboardingSchema } from "./schema";
// One module-level form for the whole feature; everything below imports
// these hooks instead of receiving `form` via props or a provider. (This is
// the createFormHooks singleton pattern — for a per-mount lifecycle, use
// useForm + createFormContext instead.)
export const onboardingForm = createForm(onboardingSchema, {
initialValues: blankOnboardingValues,
mode: "onBlur",
});
export const {
useOnboardingField,
useOnboardingFieldArray,
useOnboardingSelector,
useOnboardingIsDirty,
useOnboardingIsValid,
useOnboardingIsSubmitting,
useOnboardingSubmitCount,
} = createFormHooks(onboardingForm, "onboarding");schema.ts: schema + select options as one source of truth
import { z } from "zod";
// Select options live here as the single source of truth: the schema
// derives its enum values from them, the field components render them.
export const REGION_OPTIONS = [
{ value: "west", label: "West" },
{ value: "mountain", label: "Mountain" },
{ value: "central", label: "Central" },
{ value: "east", label: "East" },
] as const;
export const COUNTRY_OPTIONS = [
{ value: "us", label: "United States" },
{ value: "ca", label: "Canada" },
{ value: "de", label: "Germany" },
{ value: "jp", label: "Japan" },
{ value: "au", label: "Australia" },
] as const;
export const DEPARTMENT_OPTIONS = [
{ value: "engineering", label: "Engineering" },
{ value: "design", label: "Design" },
{ value: "product", label: "Product" },
{ value: "sales", label: "Sales" },
{ value: "support", label: "Support" },
] as const;
export const EMPLOYMENT_TYPE_OPTIONS = [
{ value: "full-time", label: "Full-time" },
{ value: "part-time", label: "Part-time" },
{ value: "contract", label: "Contract" },
] as const;
export const LAPTOP_OPTIONS = [
{ value: "macbook-pro", label: "MacBook Pro" },
{ value: "thinkpad", label: "ThinkPad" },
{ value: "framework", label: "Framework" },
] as const;
export const SHIRT_SIZE_OPTIONS = [
{ value: "xs", label: "XS" },
{ value: "s", label: "S" },
{ value: "m", label: "M" },
{ value: "l", label: "L" },
{ value: "xl", label: "XL" },
{ value: "2xl", label: "2XL" },
] as const;
// Tuple-preserving value extraction, so z.enum keeps the literal union
// (a plain .map() would widen to string[] and the field would type as
// string instead of the union).
const optionValues = <T extends readonly Readonly<{ value: string }>[]>(
options: T,
): { [K in keyof T]: T[K]["value"] } =>
options.map((option) => option.value) as { [K in keyof T]: T[K]["value"] };
export const onboardingSchema = z.object({
personal: z.object({
firstName: z.string().min(1, "required"),
lastName: z.string().min(1, "required"),
// "No preferred name" is null, not "" — clearing the input round-trips
// to null via field.emptyValue.
preferredName: z.string().min(1, "clear the field for none").nullable(),
email: z.email("valid email required"),
phone: z.string().min(7, "valid phone required"),
}),
address: z.object({
street: z.string().min(1, "required"),
unit: z.string().nullable(),
city: z.string().min(1, "required"),
region: z.enum(optionValues(REGION_OPTIONS), "pick a region"),
postalCode: z.string().regex(/^\d{5}$/, "5 digits"),
country: z.enum(optionValues(COUNTRY_OPTIONS), "pick a country"),
}),
employment: z.object({
jobTitle: z.string().min(1, "required"),
department: z.enum(optionValues(DEPARTMENT_OPTIONS), "pick a department"),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "pick a start date"),
employmentType: z.enum(optionValues(EMPLOYMENT_TYPE_OPTIONS)),
salary: z.number("salary required").positive("must be positive"),
remote: z.boolean(),
managerEmail: z.email("valid email required"),
}),
equipment: z.object({
laptop: z.enum(optionValues(LAPTOP_OPTIONS), "pick a laptop"),
monitorCount: z.int("whole number").min(0, "0-4").max(4, "0-4"),
needsPhone: z.boolean(),
shirtSize: z.enum(optionValues(SHIRT_SIZE_OPTIONS), "pick a size"),
notes: z.string().max(200, "200 chars max").nullable(),
}),
emergencyContacts: z
.array(
z.object({
name: z.string().min(1, "required"),
relationship: z.string().min(1, "required"),
phone: z.string().min(7, "valid phone required"),
}),
)
.min(1, "add at least one contact"),
});
// A blank draft: required numbers/enums start undefined and nullable
// fields null — deliberately not schema-satisfying yet (hence the cast);
// validation reports the gaps on submit. Shared by all three Onboarding
// modules (plain, MUI, shadcn) so the drafts can't drift.
export const blankOnboardingValues = {
personal: {
firstName: "",
lastName: "",
preferredName: null,
email: "",
phone: "",
},
address: {
street: "",
unit: null,
city: "",
region: undefined,
postalCode: "",
country: undefined,
},
employment: {
jobTitle: "",
department: undefined,
startDate: "",
employmentType: "full-time",
salary: undefined,
remote: false,
managerEmail: "",
},
equipment: {
laptop: undefined,
monitorCount: 1,
needsPhone: false,
shirtSize: undefined,
notes: null,
},
emergencyContacts: [{ name: "", relationship: "", phone: "" }],
} as unknown as z.input<typeof onboardingSchema>;A field file: CityField.tsx
import { textInputProps } from "formstand";
import { useOnboardingField } from "../hooks";
export type CityFieldProps = Readonly<{ label?: string }>;
export const useCityField = () => useOnboardingField("address.city");
export const CityField = ({ label = "City" }: CityFieldProps) => {
const field = useCityField();
return (
<div className="field">
<label htmlFor={field.path}>{label}</label>
<input id={field.path} {...textInputProps(field)} />
<span className="error">{field.error?.[0] ?? " "}</span>
</div>
);
};A section file: PersonalSection.tsx
import { useOnboardingIsDirty, useOnboardingIsValid } from "../hooks";
import { FirstNameField } from "../fields/FirstNameField";
import { LastNameField } from "../fields/LastNameField";
import { PreferredNameField } from "../fields/PreferredNameField";
import { EmailField } from "../fields/EmailField";
import { PhoneField } from "../fields/PhoneField";
export type PersonalSectionProps = Readonly<{ initiallyOpen?: boolean }>;
// The section hook is just the path-scoped flags: dirty/valid for this
// subtree only, as boolean-only subscriptions.
export const usePersonalSection = () => ({
dirty: useOnboardingIsDirty("personal"),
valid: useOnboardingIsValid("personal"),
});
export const PersonalSection = ({
initiallyOpen = true,
}: PersonalSectionProps) => {
const { dirty, valid } = usePersonalSection();
return (
<details open={initiallyOpen || undefined}>
<summary style={{ cursor: "pointer", marginBottom: 8 }}>
<strong>Personal</strong>{" "}
{dirty ? <span className="pending">edited</span> : null}{" "}
{valid ? null : <span className="error">needs attention</span>}
</summary>
<FirstNameField />
<LastNameField />
<PreferredNameField />
<EmailField />
<PhoneField />
</details>
);
};The form body: OnboardingForm.tsx
import {
onboardingForm,
useOnboardingIsDirty,
useOnboardingIsSubmitting,
useOnboardingIsValid,
useOnboardingSubmitCount,
} from "./hooks";
import { AddressSection } from "./sections/AddressSection";
import { EmergencyContactsSection } from "./sections/EmergencyContactsSection";
import { EmploymentSection } from "./sections/EmploymentSection";
import { EquipmentSection } from "./sections/EquipmentSection";
import { PersonalSection } from "./sections/PersonalSection";
// The form body composes sections; sections compose fields; fields bind
// paths. Nothing in this tree passes `form` — every hook comes pre-wired
// from ./hooks (createFormHooks over one module-level form; useForm +
// createFormContext is the per-mount alternative).
export const OnboardingForm = () => {
const isDirty = useOnboardingIsDirty();
const isValid = useOnboardingIsValid();
const isSubmitting = useOnboardingIsSubmitting();
const submitCount = useOnboardingSubmitCount();
return (
<form
onSubmit={(e) => {
e.preventDefault();
void onboardingForm.submit((data) => {
window.alert(
`welcome aboard, ${data.personal.firstName}! ` +
`(${data.emergencyContacts.length} emergency contact(s) on file)`,
);
});
}}
>
<p style={{ color: "#8b94a7", fontSize: 13, marginTop: 0 }}>
A 26-field, five-section feature module: <code>schema.ts</code> →{" "}
<code>hooks.ts</code> (<code>createFormHooks</code>) → one file per
field, one per section. Section headers show the path-scoped{" "}
<code>useOnboardingIsDirty("personal")</code> /{" "}
<code>useOnboardingIsValid("personal")</code> flags live.
</p>
<PersonalSection />
<AddressSection />
<EmploymentSection />
<EquipmentSection />
<EmergencyContactsSection />
<div className="row" style={{ marginTop: 16 }}>
<button className="primary" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting…" : "Complete onboarding"}
</button>
<button
className="secondary"
type="button"
disabled={!isDirty}
onClick={() => onboardingForm.reset()}
>
Reset
</button>
<span style={{ color: "#8b94a7", fontSize: 13 }}>
{isDirty ? "unsaved changes" : "pristine"}
{" · "}
{isValid ? "no errors" : "has errors"}
{submitCount > 0 ? ` · ${submitCount} attempt(s)` : ""}
</span>
</div>
</form>
);
};Nested + submit: NestedForm.tsx
import { useField, useForm, useFormSelector } from "formstand";
import { z } from "zod";
const schema = z.object({
name: z.string().min(2),
address: z.object({
street: z.string().min(1, "street required"),
city: z.string().min(1, "city required"),
zip: z.string().regex(/^\d{5}$/, "5-digit zip"),
}),
});
export const NestedForm = () => {
const form = useForm(schema, {
initialValues: {
name: "Tim",
address: { street: "", city: "", zip: "" },
},
});
const name = useField(form, "name");
const street = useField(form, "address.street");
const city = useField(form, "address.city");
const zip = useField(form, "address.zip");
const isSubmitting = useFormSelector(form, (s) => s.isSubmitting);
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.submit(async (data) => {
await new Promise((r) => setTimeout(r, 600));
window.alert(`submit ok: ${JSON.stringify(data)}`);
});
}}
>
<div className="field">
<label htmlFor="name">Name</label>
<input id="name"
value={name.value ?? ""}
onChange={(e) => name.setValue(e.target.value)}
onBlur={name.onBlur}
/>
<span className="error">{name.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label htmlFor="street">Street</label>
<input id="street"
value={street.value ?? ""}
onChange={(e) => street.setValue(e.target.value)}
onBlur={street.onBlur}
/>
<span className="error">{street.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label htmlFor="city">City</label>
<input id="city"
value={city.value ?? ""}
onChange={(e) => city.setValue(e.target.value)}
onBlur={city.onBlur}
/>
<span className="error">{city.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label htmlFor="zip">Zip</label>
<input id="zip"
value={zip.value ?? ""}
onChange={(e) => zip.setValue(e.target.value)}
onBlur={zip.onBlur}
/>
<span className="error">{zip.error?.[0] ?? " "}</span>
</div>
<button className="primary" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
</form>
);
};Field array: ArrayForm.tsx
import { type Form, useField, useFieldArray, useForm } from "formstand";
import { z } from "zod";
const schema = z.object({
users: z
.array(
z.object({
email: z.email("must be a valid email"),
}),
)
.min(1, "at least one user required"),
});
type Schema = typeof schema;
type UserRowProps = Readonly<{
// Typed as Form<Schema> (not the schema-less FieldFormApi) so useField
// infers each field's value type straight from the path.
form: Form<Schema>;
id: string;
index: number;
onRemove: () => void;
onUp: () => void;
onDown: () => void;
canMoveUp: boolean;
canMoveDown: boolean;
}>;
const UserRow = ({
form,
index,
onRemove,
onUp,
onDown,
canMoveUp,
canMoveDown,
}: UserRowProps) => {
const email = useField(form, `users.${index}.email`);
return (
<div className="array-item">
<div>
<input
value={email.value ?? ""}
onChange={(e) => email.setValue(e.target.value)}
onBlur={email.onBlur}
placeholder="email"
/>
<div className="error" style={{ marginTop: 4 }}>
{email.error?.[0] ?? " "}
</div>
</div>
<button className="secondary" type="button" onClick={onUp} disabled={!canMoveUp}>
↑
</button>
<button className="secondary" type="button" onClick={onDown} disabled={!canMoveDown}>
↓
</button>
<button className="secondary" type="button" onClick={onRemove}>
remove
</button>
</div>
);
};
export const ArrayForm = () => {
const form = useForm(schema, {
initialValues: { users: [{ email: "a@a.com" }, { email: "b@b.com" }] },
mode: "onBlur",
});
// The item type is inferred from the schema through the path — push()
// below knows a user is { email: string } with no annotation.
const users = useFieldArray(form, "users");
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.submit((data) => {
window.alert(`submit ok: ${JSON.stringify(data)}`);
});
}}
>
{users.fields.map((field, index) => (
<UserRow
key={field.id}
form={form}
id={field.id}
index={index}
onRemove={() => users.remove(index)}
onUp={() => users.move(index, index - 1)}
onDown={() => users.move(index, index + 1)}
canMoveUp={index > 0}
canMoveDown={index < users.length - 1}
/>
))}
{/* The ARRAY-level error (z.array(...).min(1)) — ops revalidate the
array path, so once the revalidate gate is open (post-submit),
removing the last row surfaces this line and pushing a row clears
it, live. */}
{users.error ? (
<p className="error" role="alert" style={{ margin: "4px 0 0" }}>
{users.error[0]}
</p>
) : null}
<div className="row" style={{ marginTop: 12 }}>
<button
className="secondary"
type="button"
onClick={() => users.push({ email: "" })}
>
+ add user
</button>
<button className="primary" type="submit">
Submit
</button>
</div>
</form>
);
};Async: AsyncForm.tsx
import {
textInputProps,
useField,
useForm,
useFormSelector,
} from "formstand";
import { z } from "zod";
const TAKEN = new Set(["admin", "root", "tim"]);
const schema = z.object({
username: z
.string()
.min(3, "at least 3 chars")
.refine(
async (v) => {
await new Promise((r) => setTimeout(r, 600));
return !TAKEN.has(v.toLowerCase());
},
{ message: "that username is taken" },
),
});
export const AsyncForm = () => {
const form = useForm(schema, {
initialValues: { username: "" },
mode: "onChange",
});
const username = useField(form, "username", { debounceMs: 300 });
const isSubmitting = useFormSelector(form, (s) => s.isSubmitting);
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.submit((data) => {
window.alert(`signed up: ${JSON.stringify(data)}`);
});
}}
>
<p className="subtitle">
Reserved: <code>admin</code>, <code>root</code>, <code>tim</code>.
Server check is ~600ms. Validation debounces 300ms after typing stops;
race-handling means only the last request wins.
</p>
<div className="field">
<label htmlFor="username">Username</label>
<input id="username" {...textInputProps(username)} autoComplete="off" />
<span className="error">
{username.isValidating ? (
<span className="pending">checking...</span>
) : (
(username.error?.[0] ?? " ")
)}
</span>
</div>
<button className="primary" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Signing up..." : "Sign up"}
</button>
</form>
);
};Wizard: WizardForm.tsx
import {
textInputProps,
checkboxProps,
useField,
useFormSteps,
useForm,
useIsSubmitting,
type UseFieldReturn,
} from "formstand";
import { useState } from "react";
import { z } from "zod";
// The multi-step story: ONE form and one schema behind a wizard.
// useFormSteps gates next() on the current step's fields validating
// clean (the confirm-password refine fires inside step 1), back() never
// validates, and the step headers are plain goTo() calls — a forward
// jump lands on the first step that fails instead of skipping past it.
const schema = z
.object({
email: z.string().email("not an email"),
password: z.string().min(8, "at least 8 characters"),
confirm: z.string(),
city: z.string().min(1, "required"),
agree: z.boolean().refine((v) => v, "you must agree"),
})
.superRefine((v, ctx) => {
if (v.password !== v.confirm) {
ctx.addIssue({
code: "custom",
path: ["confirm"],
message: "passwords must match",
});
}
});
const STEPS = [
{ name: "Account", fields: ["email", "password", "confirm"] },
{ name: "Shipping", fields: ["city"] },
{ name: "Review", fields: ["agree"] },
] as const;
const Field = ({
label,
field,
type,
}: Readonly<{
label: string;
field: UseFieldReturn<string>;
type?: string;
}>) => (
<div className="field">
<label htmlFor={field.path}>{label}</label>
<input id={field.path} type={type ?? "text"} {...textInputProps(field)} />
<span className="error">
{field.touched ? (field.firstError ?? " ") : " "}
</span>
</div>
);
export const WizardForm = () => {
const form = useForm(schema, {
initialValues: {
email: "",
password: "",
confirm: "",
city: "",
agree: false,
},
});
const wizard = useFormSteps(form, STEPS);
const email = useField(form, "email");
const password = useField(form, "password");
const confirm = useField(form, "confirm");
const city = useField(form, "city");
const agree = useField(form, "agree");
const isSubmitting = useIsSubmitting(form);
const [submitted, setSubmitted] = useState<string | null>(null);
return (
<form
onSubmit={form.handleSubmit((data) => {
setSubmitted(`${data.email} shipping to ${data.city}`);
})}
>
<p className="subtitle">
One form, one schema, three steps. Next validates only the current
step (try mismatched passwords); the headers jump with{" "}
<code>goTo</code> and land on the first invalid step.
</p>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
{wizard.steps.map((s, i) => (
<button
key={s.name}
type="button"
className={i === wizard.step ? "primary" : ""}
onClick={() => void wizard.goTo(i)}
>
{s.name}
{s.hasErrors ? " !" : s.visited && i !== wizard.step ? " ✓" : ""}
</button>
))}
</div>
{wizard.step === 0 && (
<>
<Field label="Email" field={email} />
<Field label="Password" field={password} type="password" />
<Field label="Confirm password" field={confirm} type="password" />
</>
)}
{wizard.step === 1 && <Field label="City" field={city} />}
{wizard.step === 2 && (
<div className="field">
<label>
<input {...checkboxProps(agree)} /> I agree to the terms
</label>
<span className="error">
{agree.touched ? (agree.firstError ?? " ") : " "}
</span>
</div>
)}
<div style={{ display: "flex", gap: 8 }}>
{!wizard.isFirst && (
<button type="button" onClick={wizard.back}>
Back
</button>
)}
{wizard.isLast ? (
<button className="primary" type="submit" disabled={isSubmitting}>
Submit
</button>
) : (
<button
className="primary"
type="button"
onClick={() => void wizard.next()}
>
Next
</button>
)}
</div>
{submitted !== null ? (
<p className="subtitle" style={{ marginTop: 12 }}>
Submitted: {submitted}
</p>
) : null}
</form>
);
};Conditional: ConditionalForm.tsx
import { SelectField, TextField, useForm, useFormSelector } from "formstand";
import { z } from "zod";
const cardRegex = /^\d{16}$/;
const accountRegex = /^\d{8,12}$/;
const schema = z
.object({
paymentMethod: z.enum(["card", "bank"]),
cardNumber: z.string(),
cvv: z.string(),
accountNumber: z.string(),
routingNumber: z.string(),
})
.superRefine((data, ctx) => {
if (data.paymentMethod === "card") {
if (!cardRegex.test(data.cardNumber)) {
ctx.addIssue({
code: "custom",
path: ["cardNumber"],
message: "16 digits required",
});
}
if (data.cvv.length !== 3) {
ctx.addIssue({
code: "custom",
path: ["cvv"],
message: "3 digits",
});
}
}
if (data.paymentMethod === "bank") {
if (!accountRegex.test(data.accountNumber)) {
ctx.addIssue({
code: "custom",
path: ["accountNumber"],
message: "8-12 digits",
});
}
if (data.routingNumber.length !== 9) {
ctx.addIssue({
code: "custom",
path: ["routingNumber"],
message: "9 digits",
});
}
}
});
export const ConditionalForm = () => {
const form = useForm(schema, {
initialValues: {
paymentMethod: "card",
cardNumber: "",
cvv: "",
accountNumber: "",
routingNumber: "",
},
mode: "onBlur",
});
const method = useFormSelector(form, (s) => s.values.paymentMethod);
return (
<form
onSubmit={form.handleSubmit((data) => {
window.alert(`paid: ${JSON.stringify(data)}`);
})}
>
<p className="subtitle">
Uses the library's bound <code>SelectField</code> /{" "}
<code>TextField</code> — labels, ids, <code>name</code>,{" "}
<code>aria-invalid</code> and error text come wired for free.
</p>
<SelectField
form={form}
path="paymentMethod"
label="Payment method"
options={[
{ value: "card", label: "Card" },
{ value: "bank", label: "Bank transfer" },
]}
/>
{method === "card" ? (
<>
<TextField form={form} path="cardNumber" label="Card number" />
<TextField form={form} path="cvv" label="CVV" />
</>
) : (
<>
<TextField form={form} path="accountNumber" label="Account number" />
<TextField
form={form}
path="routingNumber"
label="Routing number"
/>
</>
)}
<button className="primary" type="submit">
Pay
</button>
</form>
);
};Invoice: InvoiceForm.tsx
import {
type Form,
numberInputProps,
textInputProps,
useField,
useFieldArray,
useForm,
useFormSelectorShallow,
} from "formstand";
import { z } from "zod";
const lineItemSchema = z.object({
description: z.string().min(1, "required"),
quantity: z.int().positive("must be > 0"),
unitPrice: z.number().nonnegative("must be >= 0"),
});
const schema = z.object({
customer: z.string().min(1, "required"),
lineItems: z.array(lineItemSchema).min(1, "at least one item"),
});
type Schema = typeof schema;
type LineItemRowProps = Readonly<{
// Typed as Form<Schema> (not the schema-less FieldFormApi) so useField
// infers each field's value type straight from the path.
form: Form<Schema>;
index: number;
onRemove: () => void;
}>;
const LineItemRow = ({ form, index, onRemove }: LineItemRowProps) => {
const description = useField(form, `lineItems.${index}.description`);
const quantity = useField(form, `lineItems.${index}.quantity`);
const unitPrice = useField(form, `lineItems.${index}.unitPrice`);
const qty = quantity.value ?? 0;
const price = unitPrice.value ?? 0;
const subtotal = qty * price;
return (
<div
className="array-item"
style={{
display: "grid",
gridTemplateColumns: "2fr 1fr 1fr auto auto",
gap: 8,
marginBottom: 8,
alignItems: "start",
}}
>
<div>
<input
placeholder="description"
{...textInputProps(description)}
style={{ width: "100%" }}
/>
<div className="error" style={{ marginTop: 4 }}>
{description.error?.[0] ?? " "}
</div>
</div>
<div>
<input
placeholder="qty"
{...numberInputProps(quantity)}
style={{ width: "100%" }}
/>
<div className="error" style={{ marginTop: 4 }}>
{quantity.error?.[0] ?? " "}
</div>
</div>
<div>
<input
placeholder="price"
{...numberInputProps(unitPrice)}
step="0.01"
style={{ width: "100%" }}
/>
<div className="error" style={{ marginTop: 4 }}>
{unitPrice.error?.[0] ?? " "}
</div>
</div>
<div style={{ paddingTop: 10, color: "#8b94a7", fontSize: 13 }}>
${subtotal.toFixed(2)}
</div>
<button className="secondary" type="button" onClick={onRemove}>
×
</button>
</div>
);
};
const Total = ({ form }: Readonly<{ form: Form<Schema> }>) => {
const items = useFormSelectorShallow(form, (s) => s.values.lineItems ?? []);
const total = items.reduce(
(acc, item) => acc + (item.quantity ?? 0) * (item.unitPrice ?? 0),
0,
);
return (
<div style={{ textAlign: "right", marginTop: 16, fontSize: 18 }}>
Total: <strong>${total.toFixed(2)}</strong>
</div>
);
};
export const InvoiceForm = () => {
const form = useForm(schema, {
initialValues: {
customer: "",
lineItems: [{ description: "", quantity: 1, unitPrice: 0 }],
},
mode: "onBlur",
});
const customer = useField(form, "customer");
const lineItems = useFieldArray(form, "lineItems");
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.submit((data) => {
window.alert(`invoice: ${JSON.stringify(data, null, 2)}`);
});
}}
>
<div className="field">
<label htmlFor="customer">Customer</label>
<input id="customer" {...textInputProps(customer)} />
<span className="error">{customer.error?.[0] ?? " "}</span>
</div>
<div style={{ marginBottom: 12 }}>
<strong>Line items</strong>
</div>
{lineItems.fields.map((field, index) => (
<LineItemRow
key={field.id}
form={form}
index={index}
onRemove={() => lineItems.remove(index)}
/>
))}
{lineItems.error ? (
<div className="error" style={{ marginBottom: 8 }}>
{lineItems.error[0]}
</div>
) : null}
<div className="row" style={{ marginTop: 8 }}>
<button
className="secondary"
type="button"
onClick={() =>
lineItems.push({ description: "", quantity: 1, unitPrice: 0 })
}
>
+ add line
</button>
</div>
<Total form={form} />
<div className="row" style={{ marginTop: 16 }}>
<button className="primary" type="submit">
Submit invoice
</button>
</div>
</form>
);
};Nested arrays: NestedArraysForm.tsx
import {
type Form,
numberInputProps,
textInputProps,
useField,
useFieldArray,
useForm,
} from "formstand";
import { z } from "zod";
const trackSchema = z.object({
title: z.string().min(1, "title required"),
durationMin: z.number().positive("must be > 0"),
});
const albumSchema = z.object({
title: z.string().min(1, "title required"),
tracks: z.array(trackSchema).min(1, "at least one track"),
});
const schema = z.object({
albums: z.array(albumSchema).min(1, "at least one album"),
});
type Schema = typeof schema;
type TrackRowProps = Readonly<{
form: Form<Schema>;
albumIndex: number;
trackIndex: number;
onRemove: () => void;
onUp: () => void;
onDown: () => void;
canUp: boolean;
canDown: boolean;
}>;
const TrackRow = ({
form,
albumIndex,
trackIndex,
onRemove,
onUp,
onDown,
canUp,
canDown,
}: TrackRowProps) => {
const title = useField(
form,
`albums.${albumIndex}.tracks.${trackIndex}.title`,
);
const durationMin = useField(
form,
`albums.${albumIndex}.tracks.${trackIndex}.durationMin`,
);
return (
<div
className="array-item"
style={{
display: "grid",
gridTemplateColumns: "2fr 1fr auto auto auto",
gap: 8,
marginBottom: 6,
alignItems: "start",
}}
>
<div>
<input
placeholder="track title"
{...textInputProps(title)}
style={{ width: "100%" }}
/>
<div className="error" style={{ marginTop: 2 }}>
{title.error?.[0] ?? " "}
</div>
</div>
<div>
<input
placeholder="min"
{...numberInputProps(durationMin)}
style={{ width: "100%" }}
/>
<div className="error" style={{ marginTop: 2 }}>
{durationMin.error?.[0] ?? " "}
</div>
</div>
<button className="secondary" type="button" onClick={onUp} disabled={!canUp}>
↑
</button>
<button
className="secondary"
type="button"
onClick={onDown}
disabled={!canDown}
>
↓
</button>
<button className="secondary" type="button" onClick={onRemove}>
×
</button>
</div>
);
};
type AlbumRowProps = Readonly<{
form: Form<Schema>;
index: number;
onRemove: () => void;
onUp: () => void;
onDown: () => void;
canUp: boolean;
canDown: boolean;
}>;
const AlbumRow = ({
form,
index,
onRemove,
onUp,
onDown,
canUp,
canDown,
}: AlbumRowProps) => {
const albumTitle = useField(form, `albums.${index}.title`);
const tracks = useFieldArray(form, `albums.${index}.tracks`);
return (
<div
style={{
border: "1px solid #1f2530",
borderRadius: 8,
padding: 16,
marginBottom: 16,
}}
>
<div className="row" style={{ marginBottom: 12 }}>
<input
placeholder="album title"
{...textInputProps(albumTitle)}
style={{ flex: 1 }}
/>
<button className="secondary" type="button" onClick={onUp} disabled={!canUp}>
↑
</button>
<button
className="secondary"
type="button"
onClick={onDown}
disabled={!canDown}
>
↓
</button>
<button className="secondary" type="button" onClick={onRemove}>
remove album
</button>
</div>
<div className="error" style={{ marginBottom: 8 }}>
{albumTitle.error?.[0] ?? " "}
</div>
<div style={{ paddingLeft: 12, borderLeft: "2px solid #1f2530" }}>
{tracks.fields.map((field, trackIndex) => (
<TrackRow
key={field.id}
form={form}
albumIndex={index}
trackIndex={trackIndex}
onRemove={() => tracks.remove(trackIndex)}
onUp={() => tracks.move(trackIndex, trackIndex - 1)}
onDown={() => tracks.move(trackIndex, trackIndex + 1)}
canUp={trackIndex > 0}
canDown={trackIndex < tracks.length - 1}
/>
))}
{tracks.error ? (
<div className="error" style={{ marginBottom: 6 }}>
{tracks.error[0]}
</div>
) : null}
<button
className="secondary"
type="button"
onClick={() => tracks.push({ title: "", durationMin: 0 })}
>
+ add track
</button>
</div>
</div>
);
};
export const NestedArraysForm = () => {
const form = useForm(schema, {
initialValues: {
albums: [
{
title: "Kid A",
tracks: [
{ title: "Everything In Its Right Place", durationMin: 4 },
{ title: "Kid A", durationMin: 5 },
],
},
],
},
mode: "onBlur",
});
const albums = useFieldArray(form, "albums");
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.submit((data) => {
window.alert(`saved: ${JSON.stringify(data, null, 2)}`);
});
}}
>
<p className="subtitle">
Each album has its own track list. Reorder albums or tracks
independently; stable IDs keep React keys aligned with the data at
both levels.
</p>
{albums.fields.map((field, index) => (
<AlbumRow
key={field.id}
form={form}
index={index}
onRemove={() => albums.remove(index)}
onUp={() => albums.move(index, index - 1)}
onDown={() => albums.move(index, index + 1)}
canUp={index > 0}
canDown={index < albums.length - 1}
/>
))}
{albums.error ? (
<div className="error" style={{ marginBottom: 8 }}>
{albums.error[0]}
</div>
) : null}
<div className="row">
<button
className="secondary"
type="button"
onClick={() =>
albums.push({ title: "", tracks: [{ title: "", durationMin: 1 }] })
}
>
+ add album
</button>
<button className="primary" type="submit">
Save
</button>
</div>
</form>
);
};Server errors: ServerErrorsForm.tsx
import {
TextField,
focusFirstError,
useForm,
useIsSubmitting,
} from "formstand";
import { z } from "zod";
const schema = z.object({
email: z.email("must be a valid email"),
username: z.string().min(3, "min 3 chars"),
});
type ServerError = Readonly<{
field: "email" | "username";
message: string;
}>;
const fakeServer = async (
data: z.infer<typeof schema>,
): Promise<{ ok: true } | { ok: false; errors: readonly ServerError[] }> => {
await new Promise((r) => setTimeout(r, 500));
if (data.username.toLowerCase() === "admin") {
return {
ok: false,
errors: [{ field: "username", message: "username reserved" }],
};
}
if (data.email.endsWith("@banned.com")) {
return {
ok: false,
errors: [{ field: "email", message: "email domain blocked" }],
};
}
return { ok: true };
};
export const ServerErrorsForm = () => {
const form = useForm(schema, {
initialValues: { email: "", username: "" },
mode: "onBlur",
});
const isSubmitting = useIsSubmitting(form);
return (
<form
onSubmit={form.handleSubmit(
async (data) => {
const result = await fakeServer(data);
if (result.ok) {
window.alert("registered!");
form.reset();
return;
}
for (const err of result.errors) {
form.setError(err.field, err.message);
}
focusFirstError(form.getState().errors);
},
// Schema-invalid submit: jump to the first offending input.
(errors) => focusFirstError(errors),
)}
>
<p className="subtitle">
Try <code>admin</code> as username, or any <code>@banned.com</code>{" "}
email — the fake server rejects with field-level errors. Server errors
survive background revalidation and release when you edit the field;
the first offending input is focused via{" "}
<code>focusFirstError</code>.
</p>
<TextField form={form} path="email" label="Email" type="email" />
<TextField form={form} path="username" label="Username" />
<button className="primary" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Registering..." : "Register"}
</button>
</form>
);
};Autosave: AutosaveForm.tsx
import { useEffect, useState } from "react";
import {
textInputProps,
useField,
useForm,
useFormSelectorShallow,
useFormValues,
useIsDirty,
} from "formstand";
import { z } from "zod";
const STORAGE_KEY = "formstand:autosave-demo";
const DEBOUNCE_MS = 800;
const schema = z.object({
title: z.string().min(1, "title required"),
body: z.string().min(1, "body required"),
});
type Values = z.input<typeof schema>;
const loadDraft = (): Values => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return { title: "", body: "" };
const parsed = JSON.parse(raw) as unknown;
const result = schema.partial().safeParse(parsed);
if (!result.success) return { title: "", body: "" };
return { title: result.data.title ?? "", body: result.data.body ?? "" };
} catch {
return { title: "", body: "" };
}
};
export const AutosaveForm = () => {
const [initial] = useState(loadDraft);
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const [pending, setPending] = useState(false);
const [titleChanges, setTitleChanges] = useState(0);
const form = useForm(schema, { initialValues: initial, mode: "onBlur" });
const title = useField(form, "title");
const body = useField(form, "body");
const values = useFormValues(form);
const dirty = useIsDirty(form);
const dirtyPaths = useFormSelectorShallow(form, () => form.dirtyFields());
useEffect(() => {
const timerRef: { current: ReturnType<typeof setTimeout> | null } = {
current: null,
};
const unsubAll = form.watchValues((next) => {
setPending(true);
if (timerRef.current !== null) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
setLastSavedAt(Date.now());
setPending(false);
}, DEBOUNCE_MS);
});
const unsubTitle = form.watchValue("title", () => {
setTitleChanges((n) => n + 1);
});
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
unsubAll();
unsubTitle();
};
}, [form]);
const discardDraft = () => {
localStorage.removeItem(STORAGE_KEY);
form.reset({ title: "", body: "" });
setLastSavedAt(null);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.submit((data) => {
window.alert(`published: ${JSON.stringify(data)}`);
localStorage.removeItem(STORAGE_KEY);
setLastSavedAt(null);
});
}}
>
<p className="subtitle">
Draft is restored from localStorage on mount and persisted{" "}
{DEBOUNCE_MS}ms after each edit. Refresh the page to test restore.
Title edits: {titleChanges} (tracked via{" "}
<code>form.watchValue("title", ...)</code>). Changed since the restored
draft: {dirtyPaths.length > 0 ? dirtyPaths.join(", ") : "nothing"}{" "}
(from the <code>dirty</code> map — <code>form.diff()</code> would give
the matching PATCH payload).
</p>
<div className="field">
<label htmlFor="title">Title</label>
<input id="title" {...textInputProps(title)} />
<span className="error">{title.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label htmlFor="body">Body</label>
<textarea id="body"
rows={5}
{...textInputProps(body)}
style={{
background: "#0b0d12",
border: "1px solid #2a3140",
color: "#e6ebf5",
padding: "10px 12px",
borderRadius: 6,
fontSize: 14,
fontFamily: "inherit",
}}
/>
<span className="error">{body.error?.[0] ?? " "}</span>
</div>
<div
className="row"
style={{ justifyContent: "space-between", alignItems: "center" }}
>
<div style={{ fontSize: 12, color: "#8b94a7" }}>
{pending
? "Saving..."
: lastSavedAt !== null
? `Draft saved at ${new Date(lastSavedAt).toLocaleTimeString()}`
: dirty || values.title || values.body
? "Unsaved changes"
: "No draft"}
</div>
<div className="row">
<button
className="secondary"
type="button"
onClick={discardDraft}
>
Discard draft
</button>
<button className="primary" type="submit">
Publish
</button>
</div>
</div>
</form>
);
};Dependent: DependentFieldsForm.tsx
import {
textInputProps,
useField,
useForm,
useFormSelector,
} from "formstand";
import { z } from "zod";
const REGIONS = {
us: ["California", "New York", "Texas"],
uk: ["England", "Scotland", "Wales"],
ca: ["Ontario", "Quebec", "British Columbia"],
} as const;
type Country = keyof typeof REGIONS;
const schema = z.object({
country: z.enum(["us", "uk", "ca"] as const),
region: z.string().min(1, "pick a region"),
city: z.string().min(1, "city required"),
});
export const DependentFieldsForm = () => {
const form = useForm(schema, {
initialValues: { country: "us", region: "", city: "" },
mode: "onBlur",
});
const country = useField(form, "country");
const region = useField(form, "region");
const city = useField(form, "city");
const currentCountry = useFormSelector(form, (s) => s.values.country);
const onCountryChange = (next: Country) => {
country.setValue(next);
region.setValue("");
region.clearError();
};
const availableRegions = REGIONS[currentCountry];
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.submit((data) => {
window.alert(`address: ${JSON.stringify(data)}`);
});
}}
>
<p className="subtitle">
Changing country clears the region. Region options update reactively
based on the selected country.
</p>
<div className="field">
<label htmlFor="country">Country</label>
<select id="country"
value={country.value}
onChange={(e) => onCountryChange(e.target.value as Country)}
onBlur={country.onBlur}
>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
<option value="ca">Canada</option>
</select>
<span className="error">{country.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label htmlFor="region">Region</label>
<select id="region"
value={region.value}
onChange={(e) => region.setValue(e.target.value)}
onBlur={region.onBlur}
>
<option value="">Select a region...</option>
{availableRegions.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<span className="error">{region.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label htmlFor="city">City</label>
<input id="city" {...textInputProps(city)} />
<span className="error">{city.error?.[0] ?? " "}</span>
</div>
<button className="primary" type="submit">
Submit
</button>
</form>
);
};Optimistic: OptimisticForm.tsx
import { useState } from "react";
import {
textInputProps,
useField,
useForm,
useFormSelector,
} from "formstand";
import { z } from "zod";
const schema = z.object({
displayName: z.string().min(1, "required"),
bio: z.string().max(200, "max 200 chars"),
});
type Profile = z.input<typeof schema>;
const fakeServer = async (next: Profile): Promise<Profile> => {
await new Promise((r) => setTimeout(r, 800));
if (next.displayName.toLowerCase().includes("fail")) {
throw new Error("server rejected the update");
}
return next;
};
export const OptimisticForm = () => {
const [serverProfile, setServerProfile] = useState<Profile>({
displayName: "Tim",
bio: "Original bio.",
});
const [serverError, setServerError] = useState<string | null>(null);
const form = useForm(schema, {
initialValues: serverProfile,
mode: "onBlur",
});
const displayName = useField(form, "displayName");
const bio = useField(form, "bio");
const isSubmitting = useFormSelector(form, (s) => s.isSubmitting);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setServerError(null);
void form.submit(async (data) => {
try {
const saved = await fakeServer(data);
setServerProfile(saved);
form.adoptValues(saved);
} catch (err) {
form.adoptValues(serverProfile);
setServerError(err instanceof Error ? err.message : "unknown error");
}
});
};
return (
<form onSubmit={handleSubmit}>
<p className="subtitle">
Edit and submit. Including <code>fail</code> in display name simulates
a server rejection; values roll back to the last known good state.
</p>
<div className="field">
<label htmlFor="display-name">Display name</label>
<input id="display-name" {...textInputProps(displayName)} />
<span className="error">{displayName.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label htmlFor="bio">Bio</label>
<textarea id="bio"
rows={3}
{...textInputProps(bio)}
style={{
background: "#0b0d12",
border: "1px solid #2a3140",
color: "#e6ebf5",
padding: "10px 12px",
borderRadius: 6,
fontSize: 14,
fontFamily: "inherit",
}}
/>
<span className="error">{bio.error?.[0] ?? " "}</span>
</div>
{serverError !== null ? (
// Inside a .field wrapper: the playground scopes .error styling
// under .field, so a bare error div renders unstyled.
<div className="field" style={{ marginBottom: 12 }}>
<span className="error">Server error: {serverError}</span>
</div>
) : null}
<button className="primary" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</button>
<div style={{ marginTop: 16, fontSize: 12, color: "#8b94a7" }}>
Last known server state:{" "}
<code>{JSON.stringify(serverProfile)}</code>
</div>
</form>
);
};File upload: FileUploadForm.tsx
import { useField, useForm, useFormSelector } from "formstand";
import { z } from "zod";
const MAX_BYTES = 1_000_000;
const schema = z.object({
caption: z.string().min(1, "caption required"),
file: z
.instanceof(File, { message: "pick a file" })
.refine((f) => f.size <= MAX_BYTES, "max 1MB"),
});
type Values = z.input<typeof schema>;
export const FileUploadForm = () => {
const form = useForm(schema, {
initialValues: { caption: "", file: undefined as unknown as File },
mode: "onBlur",
});
const caption = useField(form, "caption");
const file = useField(form, "file");
const isSubmitting = useFormSelector(form, (s) => s.isSubmitting);
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.submit(async (data: Values) => {
await new Promise((r) => setTimeout(r, 400));
window.alert(
`uploaded ${(data.file as File).name} (${(data.file as File).size} bytes) with caption: ${data.caption}`,
);
});
}}
>
<p className="subtitle">
File objects survive the immutable spread (reference is preserved).
Persistence/autosave wouldn't work here without a custom serializer —
File isn't JSON-encodable.
</p>
<div className="field">
<label htmlFor="caption">Caption</label>
<input id="caption"
value={caption.value ?? ""}
onChange={(e) => caption.setValue(e.target.value)}
onBlur={caption.onBlur}
/>
<span className="error">{caption.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label htmlFor="file">File</label>
<input id="file"
type="file"
onChange={(e) => {
const next = e.target.files?.[0];
file.setValue((next ?? undefined) as File);
}}
onBlur={file.onBlur}
/>
<div style={{ fontSize: 12, color: "#8b94a7", marginTop: 4 }}>
{file.value instanceof File
? `${(file.value as File).name} — ${(file.value as File).size} bytes`
: "no file selected"}
</div>
<span className="error">{file.error?.[0] ?? " "}</span>
</div>
<button className="primary" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Uploading..." : "Upload"}
</button>
</form>
);
};Derived: DerivedFieldForm.tsx
import { useEffect } from "react";
import {
textInputProps,
useField,
useForm,
useFormSelector,
} from "formstand";
import { z } from "zod";
const schema = z.object({
firstName: z.string().min(1, "required"),
lastName: z.string().min(1, "required"),
displayName: z.string(),
});
export const DerivedFieldForm = () => {
const form = useForm(schema, {
initialValues: { firstName: "", lastName: "", displayName: "" },
mode: "onBlur",
});
const firstName = useField(form, "firstName");
const lastName = useField(form, "lastName");
const displayName = useFormSelector(form, (s) => s.values.displayName);
useEffect(() => {
const unsubFirst = form.watchValue("firstName", (next) => {
const last = form.getField("lastName");
form.setValue("displayName", `${next} ${last}`.trim());
});
const unsubLast = form.watchValue("lastName", (next) => {
const first = form.getField("firstName");
form.setValue("displayName", `${first} ${next}`.trim());
});
return () => {
unsubFirst();
unsubLast();
};
}, [form]);
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.submit((data) => {
window.alert(`hello, ${data.displayName}`);
});
}}
>
<p className="subtitle">
<code>displayName</code> is derived from firstName + lastName via two{" "}
<code>watchValue</code> subscriptions. Edit either source and watch
the derived value update without triggering infinite loops.
</p>
<div className="field">
<label htmlFor="first-name">First name</label>
<input id="first-name" {...textInputProps(firstName)} />
<span className="error">{firstName.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label htmlFor="last-name">Last name</label>
<input id="last-name" {...textInputProps(lastName)} />
<span className="error">{lastName.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label htmlFor="display-name-derived">Display name (derived)</label>
<input id="display-name-derived" value={displayName} readOnly disabled />
</div>
<button className="primary" type="submit">
Greet
</button>
</form>
);
};Tags: TagInputForm.tsx
import { type KeyboardEvent, useState } from "react";
import { useField, useForm } from "formstand";
import { z } from "zod";
const schema = z.object({
title: z.string().min(1, "title required"),
tags: z.array(z.string().min(1)).min(1, "at least one tag"),
});
export const TagInputForm = () => {
const form = useForm(schema, {
initialValues: { title: "", tags: [] },
mode: "onBlur",
});
const title = useField(form, "title");
const tags = useField(form, "tags");
const [draft, setDraft] = useState("");
const addTag = () => {
const next = draft.trim();
if (next === "") return;
const current = tags.value as readonly string[];
if (current.includes(next)) {
setDraft("");
return;
}
tags.setValue([...current, next]);
setDraft("");
};
const removeTag = (index: number) => {
const current = tags.value as readonly string[];
tags.setValue([...current.slice(0, index), ...current.slice(index + 1)]);
};
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag();
}
};
const items = (tags.value as readonly string[] | undefined) ?? [];
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.submit((data) => {
window.alert(`saved ${JSON.stringify(data)}`);
});
}}
>
<p className="subtitle">
A <code>string[]</code> field managed by <code>useField</code> directly
(no <code>useFieldArray</code>). Enter or comma adds, × removes.
</p>
<div className="field">
<label htmlFor="title">Title</label>
<input id="title"
value={title.value as string}
onChange={(e) => title.setValue(e.target.value)}
onBlur={title.onBlur}
/>
<span className="error">{title.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label htmlFor="tags">Tags</label>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
padding: 8,
background: "#0b0d12",
border: "1px solid #2a3140",
borderRadius: 6,
minHeight: 44,
alignItems: "center",
}}
>
{items.map((tag, index) => (
<span
key={`${tag}-${index}`}
style={{
background: "#1f2530",
color: "#d8dde6",
padding: "4px 8px",
borderRadius: 999,
fontSize: 12,
display: "inline-flex",
gap: 6,
alignItems: "center",
}}
>
{tag}
<button
type="button"
onClick={() => removeTag(index)}
style={{
background: "transparent",
border: 0,
color: "#8b94a7",
cursor: "pointer",
padding: 0,
fontSize: 14,
}}
>
×
</button>
</span>
))}
<input id="tags"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKey}
onBlur={() => {
addTag();
tags.onBlur();
}}
placeholder={items.length === 0 ? "add tag..." : ""}
style={{
background: "transparent",
border: 0,
outline: "none",
color: "#e6ebf5",
flex: 1,
minWidth: 100,
fontSize: 13,
}}
/>
</div>
<span className="error">{tags.error?.[0] ?? " "}</span>
</div>
<button className="primary" type="submit">
Save
</button>
</form>
);
};Perf: PerfBenchmarkForm.tsx
import { useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { type FieldFormApi, useField, useForm } from "formstand";
import { z } from "zod";
const SIZES = [10, 50, 200, 500] as const;
type Size = (typeof SIZES)[number];
const buildSchema = (n: number) =>
z.object(
Object.fromEntries(
Array.from({ length: n }, (_, i) => [`field${i}`, z.string()] as const),
),
);
const buildInitial = (n: number): Record<string, string> =>
Object.fromEntries(
Array.from({ length: n }, (_, i) => [`field${i}`, ""] as const),
);
type RowProps = Readonly<{
form: FieldFormApi;
path: string;
onRender: () => void;
}>;
const Row = ({ form, path, onRender }: RowProps) => {
const field = useField(form, path);
onRender();
return (
<input
value={(field.value as string) ?? ""}
onChange={(e) => field.setValue(e.target.value)}
placeholder={path}
style={{
background: "#0b0d12",
border: "1px solid #2a3140",
color: "#e6ebf5",
padding: "6px 10px",
borderRadius: 4,
fontSize: 13,
width: "100%",
}}
/>
);
};
type RunResult = Readonly<{
size: Size;
ms: number;
renderCount: number;
}>;
type BenchmarkProps = Readonly<{
size: Size;
appendResult: (r: RunResult) => void;
}>;
const Benchmark = ({ size, appendResult }: BenchmarkProps) => {
const schema = useMemo(() => buildSchema(size), [size]);
const initial = useMemo(() => buildInitial(size), [size]);
const form = useForm(schema, { initialValues: initial, mode: "onSubmit" });
// Registered like every demo — the shell mounts the state panel only
// while it's open, so a closed panel costs the benchmark nothing. (An
// OPEN panel re-renders its JSON dump inside every timed flushSync
// iteration, which is why the copy says to close it before running.)
const renderCounterRef = useRef({ count: 0 });
const onRender = () => {
renderCounterRef.current.count += 1;
};
const paths = useMemo(
() => Array.from({ length: size }, (_, i) => `field${i}`),
[size],
);
const runBenchmark = () => {
// field0 on purpose: it's the first input in the grid, so you can watch
// it tick to iter-99 without scrolling (the target field is arbitrary
// for the measurement — one subscribed field re-renders either way).
const path = "field0";
const before = renderCounterRef.current.count;
const start = performance.now();
Array.from({ length: 100 }).forEach((_, i) => {
flushSync(() => {
form.setValue(path, `iter-${i}`);
});
});
const ms = performance.now() - start;
const rendersInLoop = renderCounterRef.current.count - before;
appendResult({
size,
ms: ms / 100,
renderCount: rendersInLoop / 100,
});
};
return (
<>
<div className="row" style={{ marginBottom: 16 }}>
<button className="primary" type="button" onClick={runBenchmark}>
Run 100 setValue iterations
</button>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
gap: 4,
maxHeight: 300,
overflow: "auto",
border: "1px solid #1f2530",
borderRadius: 6,
padding: 8,
}}
>
{paths.map((path) => (
<Row key={path} form={form} path={path} onRender={onRender} />
))}
</div>
</>
);
};
export const PerfBenchmarkForm = () => {
const [size, setSize] = useState<Size>(50);
const [results, setResults] = useState<readonly RunResult[]>([]);
const appendResult = (r: RunResult) =>
setResults((prev) => [...prev, r]);
return (
<div>
<p style={{ color: "#8b94a7", fontSize: 14, marginTop: 0 }}>
Synthetic benchmark: a flat form with N string fields rendered via{" "}
<code>useField</code>. Run it and watch <code>field0</code> (top-left)
tick to <code>iter-99</code> — the "renders / setValue" column counts
how many Row components re-render per write (ideally 1: only the
targeted field). The inputs are live too; type in any of them. Keep
the View state panel <em>closed</em> while running: an open panel
re-renders its JSON dump inside every timed iteration and inflates
the numbers.
</p>
<div className="row" style={{ marginBottom: 16 }}>
<label htmlFor="form-size">Form size:</label>
<select id="form-size"
value={size}
onChange={(e) => {
setResults([]);
setSize(Number(e.target.value) as Size);
}}
>
{SIZES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<button
className="secondary"
type="button"
onClick={() => setResults([])}
>
Clear
</button>
</div>
{results.length > 0 ? (
<table
style={{
width: "100%",
marginBottom: 16,
borderCollapse: "collapse",
fontSize: 13,
}}
>
<thead>
<tr style={{ textAlign: "left", color: "#b0b8c7" }}>
<th style={{ padding: 6, borderBottom: "1px solid #1f2530" }}>
Size
</th>
<th style={{ padding: 6, borderBottom: "1px solid #1f2530" }}>
ms / setValue
</th>
<th style={{ padding: 6, borderBottom: "1px solid #1f2530" }}>
renders / setValue
</th>
</tr>
</thead>
<tbody>
{results.map((r, i) => (
<tr key={i}>
<td style={{ padding: 6 }}>{r.size}</td>
<td style={{ padding: 6 }}>{r.ms.toFixed(3)}</td>
<td style={{ padding: 6 }}>{r.renderCount.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
) : null}
<Benchmark key={size} size={size} appendResult={appendResult} />
</div>
);
};State dump panel: StateDump.tsx
import type { z } from "zod";
import type { Form } from "formstand";
import { useFormSelectorShallow } from "formstand";
export type StateDumpProps<TSchema extends z.ZodType> = Readonly<{
form: Form<TSchema>;
}>;
export const StateDump = <TSchema extends z.ZodType>({
form,
}: StateDumpProps<TSchema>) => {
const snapshot = useFormSelectorShallow(form, (state) => ({
values: state.values,
// The merged map hooks read; serverErrors is the app-owned channel that
// survives validation passes (schemaErrors is the validation-owned one).
errors: state.errors,
serverErrors: state.serverErrors,
touched: state.touched,
isSubmitting: state.isSubmitting,
submitCount: state.submitCount,
isValidating: state.isValidating,
}));
// dirtyFields() builds a fresh array per call, so it needs its own
// shallow-compared subscription — as a key inside the object above, the
// object's shallow compare would see a new array reference on every pass
// and loop the store subscription (React error #185).
const dirtyFields = useFormSelectorShallow(form, () => form.dirtyFields());
return (
<pre className="state-dump">
{JSON.stringify({ ...snapshot, dirtyFields }, null, 2)}
</pre>
);
};Material UI demos
The adapter (muiAdapter.ts): muiAdapter.ts
import type { ChangeEvent } from "react";
import type { SelectChangeEvent } from "@mui/material";
import { type UseFieldReturn, useNumberInput } from "formstand";
import { firstError, hasError } from "../fieldErrors";
// The formstand → Material UI bridge, the same adapter shape
// `formstand-gen --ui mui` emits. Each builder takes a `useField` result
// and returns a spreadable props object for the matching MUI component —
// like the library's own textInputProps, but speaking MUI's dialect:
// validity as an `error` boolean plus a `helperText` line instead of
// `aria-invalid` (MUI wires the aria attributes itself). Numbers go through
// the library's exported useNumberInput — MUI's TextField is a text input,
// and that hook is the text-preserving binding behind NumberField (partial
// entries like "-" and "1." stay visible while typing). The stateless
// numberInputProps builder remains the right choice for NATIVE
// type="number" inputs, where the browser holds partial entries itself.
// This file is the whole integration; copy the pattern for any other UI
// kit.
export type MuiTextFieldProps = Readonly<{
name: string;
value: string;
onChange: (
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => void;
onBlur: () => void;
error: boolean;
helperText: string | undefined;
}>;
export const muiTextFieldProps = <T extends string | null | undefined>(
field: UseFieldReturn<T>,
): MuiTextFieldProps => ({
name: field.path,
value: field.value ?? "",
onChange: (event) => {
const text = event.target.value;
// Clearing a nullable field writes null back (mirrors textInputProps),
// so z.string().nullable() round-trips instead of getting stuck at "".
field.setValue(
(text === "" && field.emptyValue === null ? null : text) as T,
);
},
onBlur: field.onBlur,
error: hasError(field),
helperText: firstError(field),
});
export type UseMuiNumberFieldPropsReturn = Readonly<{
name: string;
value: string;
onChange: (
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => void;
onBlur: () => void;
error: boolean;
helperText: string | undefined;
slotProps: Readonly<{ input: Readonly<{ inputMode: "decimal" }> }>;
}>;
// A hook, not a builder: useNumberInput keeps the raw text in local state
// while it doesn't parse, so intermediate entries survive keystrokes. Call
// it at component top (`const props = useMuiNumberFieldProps(field)`) and
// spread onto a TextField — inputMode rides in via slotProps.input since
// the control stays a text input.
export const useMuiNumberFieldProps = <T extends number | null | undefined>(
field: UseFieldReturn<T>,
): UseMuiNumberFieldPropsReturn => {
// Widened for useNumberInput (via unknown: setValue's parameter is
// contravariant, so a required-number field isn't directly convertible):
// clearing still writes emptyValue — the same runtime the old
// per-keystroke `as T` cast produced, asserted once here instead.
const binding = useNumberInput(
field as unknown as UseFieldReturn<number | null | undefined>,
);
return {
name: binding.name,
value: binding.value,
onChange: (event) =>
binding.onChange(event as ChangeEvent<HTMLInputElement>),
onBlur: binding.onBlur,
error: hasError(field),
helperText: firstError(field),
slotProps: { input: { inputMode: "decimal" } },
};
};
// Spread onto <Select>; pair with a FormControl + FormHelperText for the
// error message (Select has no helperText of its own).
export type MuiSelectProps = Readonly<{
name: string;
value: string;
onChange: (event: SelectChangeEvent<string>) => void;
onBlur: () => void;
error: boolean;
}>;
export const muiSelectProps = <T extends string | null | undefined>(
field: UseFieldReturn<T>,
): MuiSelectProps => ({
name: field.path,
value: field.value ?? "",
onChange: (event) => {
const next = event.target.value;
field.setValue(
(next === "" && field.emptyValue === null ? null : next) as T,
);
},
onBlur: field.onBlur,
error: hasError(field),
});
export type MuiSwitchProps = Readonly<{
name: string;
checked: boolean;
onChange: (
event: ChangeEvent<HTMLInputElement>,
checked: boolean,
) => void;
onBlur: () => void;
}>;
export const muiSwitchProps = <T extends boolean | null | undefined>(
field: UseFieldReturn<T>,
): MuiSwitchProps => ({
name: field.path,
checked: field.value ?? false,
onChange: (_event, checked) => field.setValue(checked as T),
onBlur: field.onBlur,
});
// Sliders fire onChange continuously while dragging; validation waits for
// onChangeCommitted (mapped to the field's blur trigger) so onBlur-mode
// forms don't validate sixty times a second mid-drag.
export type MuiSliderProps = Readonly<{
name: string;
value: number;
onChange: (event: Event, value: number) => void;
onChangeCommitted: () => void;
}>;
export const muiSliderProps = <T extends number>(
field: UseFieldReturn<T>,
): MuiSliderProps => ({
name: field.path,
value: field.value,
onChange: (_event, value) => field.setValue(value as T),
onChangeCommitted: () => field.onBlur(),
});Theme bridge: MuiThemeBridge.tsx
import type { ReactNode } from "react";
import { ScopedCssBaseline } from "@mui/material";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { useThemeMode } from "../theme";
// One theme instance per mode (module scope — a fresh theme per render
// would remount the whole emotion cache). The bridge follows the shell's
// light/dark switch via ThemeModeContext.
const themes = {
dark: createTheme({ palette: { mode: "dark" } }),
light: createTheme({ palette: { mode: "light" } }),
} as const;
export type MuiThemeBridgeProps = Readonly<{ children: ReactNode }>;
// ScopedCssBaseline instead of CssBaseline: the global baseline would
// restyle the entire playground shell, the scoped one normalizes only the
// MUI subtree. Transparent background so the demo sits on the playground
// card instead of painting MUI's own canvas color over it.
export const MuiThemeBridge = ({ children }: MuiThemeBridgeProps) => {
const mode = useThemeMode();
return (
<ThemeProvider theme={themes[mode]}>
<ScopedCssBaseline sx={{ bgcolor: "transparent" }}>
{children}
</ScopedCssBaseline>
</ThemeProvider>
);
};MUI: Checkout: MuiCheckoutWizard.tsx
import { type FormEvent, useState } from "react";
import {
Alert,
Box,
Button,
Divider,
FormControl,
FormControlLabel,
FormHelperText,
Grid,
InputLabel,
List,
ListItem,
ListItemText,
MenuItem,
Select,
Snackbar,
Step,
StepLabel,
Stepper,
Switch,
TextField,
Typography,
} from "@mui/material";
import {
type Form,
focusFirstError,
useField,
useForm,
useFormValues,
} from "formstand";
import { z } from "zod";
import {
muiSelectProps,
muiSwitchProps,
muiTextFieldProps,
} from "./muiAdapter";
const COUNTRIES = [
{ value: "US", label: "United States" },
{ value: "CA", label: "Canada" },
{ value: "GB", label: "United Kingdom" },
{ value: "DE", label: "Germany" },
] as const;
const addressSchema = z.object({
street: z.string().min(1, "street required"),
city: z.string().min(1, "city required"),
postalCode: z.string().min(3, "postal code required"),
country: z.enum(["US", "CA", "GB", "DE"]),
});
const schema = z.object({
contact: z.object({
fullName: z.string().min(1, "name required"),
email: z.email("valid email required"),
}),
shipping: addressSchema,
billingSameAsShipping: z.boolean(),
billing: addressSchema,
payment: z.object({
cardNumber: z.string().regex(/^\d{16}$/, "card number is 16 digits"),
expiry: z.string().regex(/^(0[1-9]|1[0-2])\/\d\d$/, "use MM/YY"),
cvc: z.string().regex(/^\d{3,4}$/, "3 or 4 digits"),
}),
});
type Schema = typeof schema;
type Values = z.input<Schema>;
type Address = Values["shipping"];
const EMPTY_ADDRESS: Address = {
street: "",
city: "",
postalCode: "",
country: "US",
};
const STEP_LABELS = ["Contact & shipping", "Payment", "Review"] as const;
const CONTACT_SHIPPING_PATHS = [
"contact.fullName",
"contact.email",
"shipping.street",
"shipping.city",
"shipping.postalCode",
"shipping.country",
] as const;
const BILLING_PATHS = [
"billing.street",
"billing.city",
"billing.postalCode",
"billing.country",
] as const;
const PAYMENT_PATHS = [
"payment.cardNumber",
"payment.expiry",
"payment.cvc",
] as const;
const formatAddress = (address: Address): string =>
`${address.street}, ${address.city} ${address.postalCode}, ${address.country}`;
type StepProps = Readonly<{ form: Form<Schema> }>;
type AddressFieldsProps = Readonly<{
form: Form<Schema>;
prefix: "shipping" | "billing";
}>;
const AddressFields = ({ form, prefix }: AddressFieldsProps) => {
const street = useField(form, `${prefix}.street`);
const city = useField(form, `${prefix}.city`);
const postalCode = useField(form, `${prefix}.postalCode`);
const country = useField(form, `${prefix}.country`);
const countryProps = muiSelectProps(country);
return (
<Grid container spacing={2}>
<Grid size={{ xs: 12 }}>
<TextField label="Street" fullWidth {...muiTextFieldProps(street)} />
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField label="City" fullWidth {...muiTextFieldProps(city)} />
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
label="Postal code"
fullWidth
{...muiTextFieldProps(postalCode)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<FormControl fullWidth error={countryProps.error}>
<InputLabel id={`${prefix}-country-label`}>Country</InputLabel>
<Select
labelId={`${prefix}-country-label`}
label="Country"
{...countryProps}
>
{COUNTRIES.map((c) => (
<MenuItem key={c.value} value={c.value}>
{c.label}
</MenuItem>
))}
</Select>
{country.error?.[0] !== undefined ? (
<FormHelperText>{country.error[0]}</FormHelperText>
) : null}
</FormControl>
</Grid>
</Grid>
);
};
const ContactShippingStep = ({ form }: StepProps) => {
const fullName = useField(form, "contact.fullName");
const email = useField(form, "contact.email");
const billingSame = useField(form, "billingSameAsShipping");
const billingSwitch = muiSwitchProps(billingSame);
return (
<Box>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
label="Full name"
fullWidth
{...muiTextFieldProps(fullName)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField label="Email" fullWidth {...muiTextFieldProps(email)} />
</Grid>
</Grid>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Shipping address
</Typography>
<AddressFields form={form} prefix="shipping" />
<FormControlLabel
sx={{ my: 1 }}
label="Billing address same as shipping"
control={
<Switch
{...billingSwitch}
onChange={(event, checked) => {
billingSwitch.onChange(event, checked);
if (checked) {
// Copy shipping over the billing section as it unmounts, and
// drop any errors it collected while visible.
form.setValue("billing", form.getState().values.shipping);
form.clearErrors("billing");
}
}}
/>
}
/>
{billingSame.value ? null : (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Billing address
</Typography>
<AddressFields form={form} prefix="billing" />
</Box>
)}
</Box>
);
};
const PaymentStep = ({ form }: StepProps) => {
const cardNumber = useField(form, "payment.cardNumber");
const expiry = useField(form, "payment.expiry");
const cvc = useField(form, "payment.cvc");
return (
<Grid container spacing={2}>
<Grid size={{ xs: 12 }}>
<TextField
label="Card number"
fullWidth
autoComplete="off"
{...muiTextFieldProps(cardNumber)}
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<TextField
label="Expiry (MM/YY)"
fullWidth
{...muiTextFieldProps(expiry)}
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<TextField
label="CVC"
fullWidth
autoComplete="off"
{...muiTextFieldProps(cvc)}
/>
</Grid>
</Grid>
);
};
// Its own component so only the Review step subscribes to every value — the
// useFormValues whole-values subscription is isolated to this subtree, so
// the wizard shell doesn't re-render per keystroke on earlier steps.
const ReviewStep = ({ form }: StepProps) => {
const values = useFormValues(form);
const rows: readonly Readonly<{ label: string; text: string }>[] = [
{ label: "Name", text: values.contact.fullName },
{ label: "Email", text: values.contact.email },
{ label: "Ships to", text: formatAddress(values.shipping) },
{
label: "Bills to",
text: values.billingSameAsShipping
? "Same as shipping"
: formatAddress(values.billing),
},
{
label: "Card",
text: `**** **** **** ${values.payment.cardNumber.slice(-4)} (exp ${values.payment.expiry})`,
},
];
return (
<Box>
<Alert severity="info" sx={{ mb: 1 }}>
Everything below is read straight from the store via a selector — the
inputs from earlier steps are unmounted, the values persist.
</Alert>
<List dense>
{rows.map((row) => (
<ListItem key={row.label} disableGutters>
<ListItemText primary={row.label} secondary={row.text} />
</ListItem>
))}
</List>
</Box>
);
};
export const MuiCheckoutWizard = () => {
const form = useForm(schema, {
initialValues: {
contact: { fullName: "", email: "" },
shipping: EMPTY_ADDRESS,
billingSameAsShipping: true,
billing: EMPTY_ADDRESS,
payment: { cardNumber: "", expiry: "", cvc: "" },
},
mode: "onBlur",
});
const [step, setStep] = useState(0);
const [orderPlaced, setOrderPlaced] = useState(false);
// Keep the hidden billing copy in sync with shipping whenever "same as
// shipping" holds, so whole-form validation never trips on stale values
// behind an unmounted section.
const syncBillingCopy = () => {
const values = form.getState().values;
if (values.billingSameAsShipping) {
form.setValue("billing", values.shipping);
form.clearErrors("billing");
}
};
const handleNext = async () => {
syncBillingCopy();
const paths =
step === 0
? form.getState().values.billingSameAsShipping
? CONTACT_SHIPPING_PATHS
: [...CONTACT_SHIPPING_PATHS, ...BILLING_PATHS]
: PAYMENT_PATHS;
// validateFields settles synchronously for sync schemas; an async schema
// returns { kind: "pending", promise } — awaiting the promise covers both.
const result = form.validateFields(paths);
const settled = result.kind === "pending" ? await result.promise : result;
if (settled.kind === "valid") {
setStep((s) => s + 1);
} else {
focusFirstError(form.getState().errors);
}
};
const handleFormSubmit = (event: FormEvent<HTMLFormElement>) => {
syncBillingCopy();
void form.handleSubmit(
() => setOrderPlaced(true),
(errors) => focusFirstError(errors),
)(event);
};
return (
<form onSubmit={handleFormSubmit} noValidate>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Each step gates on <code>form.validateFields(stepPaths)</code>; the
final submit runs the whole schema and jumps to the first offending
input via <code>focusFirstError</code>.
</Typography>
<Stepper activeStep={step} sx={{ mb: 3 }}>
{STEP_LABELS.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
{step === 0 ? <ContactShippingStep form={form} /> : null}
{step === 1 ? <PaymentStep form={form} /> : null}
{step === 2 ? <ReviewStep form={form} /> : null}
<Divider sx={{ my: 2 }} />
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
disabled={step === 0}
onClick={() => setStep((s) => s - 1)}
>
Back
</Button>
{step < 2 ? (
<Button variant="contained" onClick={() => void handleNext()}>
Next
</Button>
) : (
<Button variant="contained" type="submit">
Place order
</Button>
)}
</Box>
<Snackbar
open={orderPlaced}
autoHideDuration={4000}
onClose={() => setOrderPlaced(false)}
>
<Alert
severity="success"
variant="filled"
onClose={() => setOrderPlaced(false)}
>
Order placed — the typed payload came out of handleSubmit.
</Alert>
</Snackbar>
</form>
);
};MUI: Job form: MuiJobApplication.tsx
import { useState } from "react";
import {
Alert,
Autocomplete,
Box,
Button,
CircularProgress,
FormControlLabel,
FormHelperText,
Grid,
InputAdornment,
Slider,
Snackbar,
Switch,
TextField,
Typography,
} from "@mui/material";
import {
focusFirstError,
useField,
useForm,
useIsSubmitting,
} from "formstand";
import { z } from "zod";
import {
muiSliderProps,
muiSwitchProps,
muiTextFieldProps,
useMuiNumberFieldProps,
} from "./muiAdapter";
const SKILLS: readonly string[] = [
"TypeScript",
"React",
"Node.js",
"GraphQL",
"Python",
"Rust",
"SQL",
"Docker",
];
const TAKEN_EMAILS: ReadonlySet<string> = new Set([
"taken@example.com",
"admin@example.com",
]);
const SALARY_MIN = 50_000;
const SALARY_MAX = 250_000;
const SERVER_SALARY_CAP = 200_000;
const schema = z.object({
email: z.email("valid email required").refine(
async (value) => {
await new Promise((resolve) => setTimeout(resolve, 600));
return !TAKEN_EMAILS.has(value.toLowerCase());
},
{ message: "an application with this email already exists" },
),
skills: z.array(z.string()).min(1, "pick at least one skill"),
salary: z.number().min(SALARY_MIN).max(SALARY_MAX),
yearsExperience: z
.int("whole years only")
.min(0, "cannot be negative")
.max(50, "50 years max"),
remoteOk: z.boolean(),
});
const usd = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const formatUsd = (value: number): string => usd.format(value);
export const MuiJobApplication = () => {
const form = useForm(schema, {
initialValues: {
email: "",
skills: [],
salary: 90_000,
yearsExperience: 3,
remoteOk: true,
},
mode: "onChange",
});
// debounceMs holds the async availability check until typing pauses.
const email = useField(form, "email", { debounceMs: 300 });
const skills = useField(form, "skills");
const salary = useField(form, "salary");
const years = useField(form, "yearsExperience");
const remoteOk = useField(form, "remoteOk");
const isSubmitting = useIsSubmitting(form);
const [submitted, setSubmitted] = useState(false);
const emailProps = muiTextFieldProps(email);
const salaryProps = muiSliderProps(salary);
const yearsProps = useMuiNumberFieldProps(years);
const skillsInvalid = skills.error !== undefined && skills.error.length > 0;
return (
<form
onSubmit={form.handleSubmit(
async (data) => {
await new Promise((resolve) => setTimeout(resolve, 500));
if (data.salary > SERVER_SALARY_CAP) {
// Server-channel rejection: this error survives background
// revalidation and only releases when the field is edited.
form.setError(
"salary",
`server says: salary cap for this role is ${formatUsd(SERVER_SALARY_CAP)}`,
);
focusFirstError(form.getState().errors);
return;
}
setSubmitted(true);
},
(errors) => focusFirstError(errors),
)}
noValidate
>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
The email check is an async <code>.refine</code> (600ms fake server,
300ms debounce) — <code>taken@example.com</code> is taken. Ask for
more than {formatUsd(SERVER_SALARY_CAP)} and the submit handler
rejects via <code>form.setError</code>: that server error sticks
until you move the slider again.
</Typography>
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
label="Email"
fullWidth
autoComplete="off"
{...emailProps}
helperText={
email.isValidating
? "checking availability..."
: emailProps.helperText
}
slotProps={{
input: {
endAdornment: email.isValidating ? (
<InputAdornment position="end">
<CircularProgress size={18} />
</InputAdornment>
) : null,
},
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField label="Years of experience" fullWidth {...yearsProps} />
</Grid>
<Grid size={{ xs: 12 }}>
<Autocomplete
multiple
options={SKILLS}
value={skills.value}
onChange={(_event, value) => skills.setValue(value)}
onBlur={skills.onBlur}
renderInput={(params) => (
<TextField
{...params}
label="Skills"
name={skills.path}
error={skillsInvalid}
helperText={
skills.error?.[0] ?? "the whole array is one field"
}
/>
)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 8 }}>
<Typography gutterBottom>
Expected salary: {formatUsd(salary.value)}
</Typography>
<Slider
{...salaryProps}
min={SALARY_MIN}
max={SALARY_MAX}
step={5_000}
valueLabelDisplay="auto"
valueLabelFormat={formatUsd}
marks={[
{ value: SALARY_MIN, label: formatUsd(SALARY_MIN) },
{ value: SALARY_MAX, label: formatUsd(SALARY_MAX) },
]}
/>
{salary.error?.[0] !== undefined ? (
<FormHelperText error>{salary.error[0]}</FormHelperText>
) : null}
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<FormControlLabel
control={<Switch {...muiSwitchProps(remoteOk)} />}
label="Open to remote"
/>
</Grid>
</Grid>
<Box sx={{ mt: 3 }}>
<Button
variant="contained"
type="submit"
disabled={isSubmitting || email.isValidating}
>
{isSubmitting ? "Submitting..." : "Apply"}
</Button>
</Box>
<Snackbar
open={submitted}
autoHideDuration={4000}
onClose={() => setSubmitted(false)}
>
<Alert
severity="success"
variant="filled"
onClose={() => setSubmitted(false)}
>
Application submitted.
</Alert>
</Snackbar>
</form>
);
};MUI: Invoice: MuiInvoiceBuilder.tsx
import { useState } from "react";
import AddIcon from "@mui/icons-material/Add";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import DeleteIcon from "@mui/icons-material/Delete";
import {
Alert,
Box,
Button,
FormControl,
IconButton,
MenuItem,
Select,
Snackbar,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import {
type Form,
focusField,
focusFirstError,
useField,
useFieldArray,
useForm,
useFormSelector,
useIsDirty,
useIsSubmitting,
} from "formstand";
import { z } from "zod";
import {
muiSelectProps,
muiTextFieldProps,
useMuiNumberFieldProps,
} from "./muiAdapter";
const CATEGORIES = [
{ value: "services", label: "Services" },
{ value: "goods", label: "Goods" },
{ value: "expenses", label: "Expenses" },
] as const;
const lineItemSchema = z.object({
description: z.string().min(1, "required"),
category: z.enum(["services", "goods", "expenses"]),
quantity: z.int("whole numbers").positive("must be > 0"),
unitPrice: z.number().nonnegative("must be >= 0"),
});
const schema = z.object({
customer: z.string().min(1, "customer required"),
items: z.array(lineItemSchema).min(1, "an invoice needs at least one line item"),
});
type Schema = typeof schema;
type LineItem = z.input<typeof lineItemSchema>;
const NEW_ITEM: LineItem = {
description: "",
category: "services",
quantity: 1,
unitPrice: 0,
};
const usd = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
type ItemRowProps = Readonly<{
form: Form<Schema>;
index: number;
count: number;
onRemove: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}>;
const ItemRow = ({
form,
index,
count,
onRemove,
onMoveUp,
onMoveDown,
}: ItemRowProps) => {
const description = useField(form, `items.${index}.description`);
const category = useField(form, `items.${index}.category`);
const quantity = useField(form, `items.${index}.quantity`);
const unitPrice = useField(form, `items.${index}.unitPrice`);
const categoryProps = muiSelectProps(category);
const quantityProps = useMuiNumberFieldProps(quantity);
const unitPriceProps = useMuiNumberFieldProps(unitPrice);
const rowTotal = (quantity.value ?? 0) * (unitPrice.value ?? 0);
return (
<TableRow>
<TableCell sx={{ minWidth: 180 }}>
<TextField
variant="standard"
fullWidth
placeholder="Description"
{...muiTextFieldProps(description)}
/>
</TableCell>
<TableCell sx={{ minWidth: 130 }}>
<FormControl variant="standard" fullWidth error={categoryProps.error}>
<Select {...categoryProps}>
{CATEGORIES.map((c) => (
<MenuItem key={c.value} value={c.value}>
{c.label}
</MenuItem>
))}
</Select>
</FormControl>
</TableCell>
<TableCell sx={{ width: 90 }}>
<TextField variant="standard" fullWidth {...quantityProps} />
</TableCell>
<TableCell sx={{ width: 110 }}>
<TextField variant="standard" fullWidth {...unitPriceProps} />
</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{usd.format(rowTotal)}
</TableCell>
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
<IconButton
size="small"
aria-label="move up"
disabled={index === 0}
onClick={onMoveUp}
>
<ArrowUpwardIcon fontSize="inherit" />
</IconButton>
<IconButton
size="small"
aria-label="move down"
disabled={index === count - 1}
onClick={onMoveDown}
>
<ArrowDownwardIcon fontSize="inherit" />
</IconButton>
<IconButton size="small" aria-label="delete row" onClick={onRemove}>
<DeleteIcon fontSize="inherit" />
</IconButton>
</TableCell>
</TableRow>
);
};
// Grand total in its own component: only this subtree re-renders as
// quantities and prices change.
const GrandTotal = ({ form }: Readonly<{ form: Form<Schema> }>) => {
const items = useFormSelector(form, (s) => s.values.items);
const total = items.reduce(
(acc, item) => acc + (item.quantity ?? 0) * (item.unitPrice ?? 0),
0,
);
return (
<Typography variant="h6" sx={{ textAlign: "right", mt: 1 }}>
Total: {usd.format(total)}
</Typography>
);
};
export const MuiInvoiceBuilder = () => {
const form = useForm(schema, {
initialValues: {
customer: "Acme Corp",
items: [
{
description: "Design sprint",
category: "services",
quantity: 3,
unitPrice: 1200,
},
{
description: "Standing desk",
category: "goods",
quantity: 2,
unitPrice: 480.5,
},
],
},
mode: "onBlur",
});
const customer = useField(form, "customer");
const items = useFieldArray(form, "items");
const isDirty = useIsDirty(form);
const isSubmitting = useIsSubmitting(form);
const [saved, setSaved] = useState(false);
return (
<form
onSubmit={form.handleSubmit(
async () => {
await new Promise((resolve) => setTimeout(resolve, 400));
// Rebase: the just-saved values become the new baseline, so the
// form reads clean and Save disables until the next edit.
form.adoptValues(form.getState().values);
setSaved(true);
},
(errors) => focusFirstError(errors),
)}
noValidate
>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
A <code>useFieldArray</code> over a MUI table. Save is dirty-gated:
it enables on the first edit and, after a successful save,{" "}
<code>adoptValues</code> rebases the initial values so it disables
again. Delete both rows to see the array-level{" "}
<code>z.array(...).min(1)</code> error.
</Typography>
<TextField
label="Customer"
sx={{ mb: 2, maxWidth: 360 }}
fullWidth
{...muiTextFieldProps(customer)}
/>
{/* A five-column table can't stack, so below ~600px it scrolls sideways
* inside its own container rather than collapsing its cells to 20px.
* minWidth keeps the columns legible while that scroll happens. */}
<TableContainer sx={{ overflowX: "auto" }}>
<Table size="small" sx={{ minWidth: 520 }}>
<TableHead>
<TableRow>
<TableCell>Description</TableCell>
<TableCell>Category</TableCell>
<TableCell>Qty</TableCell>
<TableCell>Unit price</TableCell>
<TableCell align="right">Amount</TableCell>
<TableCell align="right" />
</TableRow>
</TableHead>
<TableBody>
{items.fields.map((field, index) => (
<ItemRow
key={field.id}
form={form}
index={index}
count={items.length}
onRemove={() => items.remove(index)}
onMoveUp={() => items.move(index, index - 1)}
onMoveDown={() => items.move(index, index + 1)}
/>
))}
</TableBody>
</Table>
</TableContainer>
{items.error?.[0] !== undefined ? (
<Alert severity="error" sx={{ mt: 2 }}>
{items.error[0]}
</Alert>
) : null}
<GrandTotal form={form} />
<Box sx={{ display: "flex", gap: 1, mt: 2 }}>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={() => {
// The new row's index is the PRE-push length — read it before
// the op. focusField waits a frame so the row exists in the
// DOM, then lands on its description input (the library's
// "setFocus", keyed by path).
const newIndex = items.length;
items.push(NEW_ITEM);
requestAnimationFrame(() =>
focusField(`items.${newIndex}.description`),
);
}}
>
Add line
</Button>
<Button
variant="contained"
type="submit"
disabled={!isDirty || isSubmitting}
>
{isSubmitting ? "Saving..." : "Save invoice"}
</Button>
</Box>
<Snackbar
open={saved}
autoHideDuration={4000}
onClose={() => setSaved(false)}
>
<Alert
severity="success"
variant="filled"
onClose={() => setSaved(false)}
>
Invoice saved — adoptValues rebased the form to clean.
</Alert>
</Snackbar>
</form>
);
};MUI: Settings: MuiProfileSettings.tsx
import { useState } from "react";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
FormControl,
FormControlLabel,
FormHelperText,
InputLabel,
MenuItem,
Select,
Snackbar,
Stack,
Switch,
TextField,
Typography,
} from "@mui/material";
import {
type Form,
useField,
useForm,
useFormSelectorShallow,
useIsDirty,
useIsValid,
} from "formstand";
import { z } from "zod";
import {
muiSelectProps,
muiSwitchProps,
muiTextFieldProps,
} from "./muiAdapter";
const THEMES = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
] as const;
const schema = z.object({
displayName: z.string().min(1, "display name required"),
// Nullable on purpose: "no bio" is null, not "". Clearing the text field
// writes null back via field.emptyValue.
bio: z.string().max(160, "160 chars max").nullable(),
theme: z.enum(["system", "light", "dark"]),
notifications: z.object({
email: z.boolean(),
push: z.boolean(),
weeklyDigest: z.boolean(),
}),
});
type Schema = typeof schema;
// The chips row gets its own shallow selector: dirtyFields() builds a fresh
// array per call, so it must be the selector's direct result (compared
// element-wise by useShallow), never a property of a composite selector
// object.
const DirtyChips = ({ form }: Readonly<{ form: Form<Schema> }>) => {
const dirtyPaths = useFormSelectorShallow(form, () => form.dirtyFields());
return (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Unsaved changes ({dirtyPaths.length})
</Typography>
{dirtyPaths.length === 0 ? (
<Typography variant="body2" color="text.secondary">
Everything matches the last saved state.
</Typography>
) : (
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: "wrap" }}>
{dirtyPaths.map((path) => (
<Chip
key={path}
label={path}
size="small"
color="warning"
variant="outlined"
/>
))}
</Stack>
)}
</Box>
);
};
export const MuiProfileSettings = () => {
const form = useForm(schema, {
initialValues: {
displayName: "Tim",
bio: null,
theme: "system",
notifications: { email: true, push: false, weeklyDigest: true },
},
mode: "onChange",
});
const displayName = useField(form, "displayName");
const bio = useField(form, "bio");
const theme = useField(form, "theme");
const emailNotif = useField(form, "notifications.email");
const pushNotif = useField(form, "notifications.push");
const digestNotif = useField(form, "notifications.weeklyDigest");
const isDirty = useIsDirty(form);
const isValid = useIsValid(form);
const [saved, setSaved] = useState(false);
const themeProps = muiSelectProps(theme);
const bioText =
bio.value === null ? "null" : JSON.stringify(bio.value);
return (
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Save is gated on <code>useIsDirty && useIsValid</code>; the chips
below are <code>form.dirtyFields()</code> live. Saving calls{" "}
<code>adoptValues</code> (rebase, not reset) — Discard calls{" "}
<code>reset()</code> back to the last saved state.
</Typography>
<DirtyChips form={form} />
<Stack spacing={2}>
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom>
Profile
</Typography>
<Stack spacing={2}>
<TextField
label="Display name"
fullWidth
{...muiTextFieldProps(displayName)}
/>
<TextField
label="Bio"
fullWidth
multiline
minRows={2}
{...muiTextFieldProps(bio)}
helperText={
bio.error?.[0] ??
`bio is a nullable field — its store value is ${bioText}. ` +
"Clear the text and it round-trips to null " +
"(field.emptyValue), not an empty string."
}
/>
</Stack>
</CardContent>
</Card>
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom>
Preferences
</Typography>
<FormControl sx={{ minWidth: 220, mb: 1 }} error={themeProps.error}>
<InputLabel id="settings-theme-label">Theme</InputLabel>
<Select
labelId="settings-theme-label"
label="Theme"
{...themeProps}
>
{THEMES.map((t) => (
<MenuItem key={t.value} value={t.value}>
{t.label}
</MenuItem>
))}
</Select>
{theme.error?.[0] !== undefined ? (
<FormHelperText>{theme.error[0]}</FormHelperText>
) : null}
</FormControl>
<Box>
<FormControlLabel
control={<Switch {...muiSwitchProps(emailNotif)} />}
label="Email notifications"
/>
<FormControlLabel
control={<Switch {...muiSwitchProps(pushNotif)} />}
label="Push notifications"
/>
<FormControlLabel
control={<Switch {...muiSwitchProps(digestNotif)} />}
label="Weekly digest"
/>
</Box>
</CardContent>
</Card>
<Card variant="outlined" sx={{ borderColor: "error.main" }}>
<CardContent>
<Typography variant="h6" color="error" gutterBottom>
Danger zone
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Throw away every unsaved change and return to the last saved
state.
</Typography>
<Button
variant="outlined"
color="error"
disabled={!isDirty}
onClick={() => form.reset()}
>
Discard changes
</Button>
</CardContent>
</Card>
</Stack>
<Box sx={{ mt: 2 }}>
<Button
variant="contained"
disabled={!isDirty || !isValid}
onClick={() =>
void form.handleSubmit(() => {
form.adoptValues(form.getState().values);
setSaved(true);
})()
}
>
Save settings
</Button>
</Box>
<Snackbar
open={saved}
autoHideDuration={4000}
onClose={() => setSaved(false)}
>
<Alert
severity="success"
variant="filled"
onClose={() => setSaved(false)}
>
Settings saved — the dirty chips are gone until the next edit.
</Alert>
</Snackbar>
</Box>
);
};MUI: Survey: MuiSurveyBuilder.tsx
import { useState } from "react";
import AddIcon from "@mui/icons-material/Add";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import DeleteIcon from "@mui/icons-material/Delete";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
FormControl,
IconButton,
InputLabel,
MenuItem,
Select,
Snackbar,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from "@mui/material";
import {
type Form,
focusFirstError,
useField,
useFieldArray,
useForm,
useFormError,
useFormSelector,
} from "formstand";
import { z } from "zod";
import { muiSelectProps, muiTextFieldProps } from "./muiAdapter";
const QUESTION_TYPES = [
{ value: "text", label: "Free text" },
{ value: "choice", label: "Multiple choice" },
{ value: "scale", label: "Scale" },
] as const;
const questionSchema = z
.object({
prompt: z.string().min(1, "prompt required"),
type: z.enum(["text", "choice", "scale"]),
// Comma-separated; only meaningful (and only validated) for "choice".
options: z.string(),
scaleMax: z.union([z.literal(5), z.literal(10)]),
})
.refine((q) => q.type !== "choice" || q.options.trim().length > 0, {
message: "choice questions need at least one option",
path: ["options"],
});
const sectionSchema = z.object({
title: z.string().min(1, "section title required"),
questions: z.array(questionSchema),
});
const schema = z
.object({
title: z.string().min(1, "survey title required"),
sections: z.array(sectionSchema).min(1, "add at least one section"),
})
// No path: this refine lands at the root "" key, read by useFormError.
.refine(
(s) => s.sections.some((section) => section.questions.length > 0),
{ message: "a survey needs at least one question overall" },
);
type Schema = typeof schema;
type Section = z.input<typeof sectionSchema>;
type Question = z.input<typeof questionSchema>;
const NEW_QUESTION: Question = {
prompt: "",
type: "text",
options: "",
scaleMax: 5,
};
const NEW_SECTION: Section = { title: "", questions: [NEW_QUESTION] };
type QuestionEditorProps = Readonly<{
form: Form<Schema>;
sectionIndex: number;
questionIndex: number;
count: number;
onRemove: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}>;
const QuestionEditor = ({
form,
sectionIndex,
questionIndex,
count,
onRemove,
onMoveUp,
onMoveDown,
}: QuestionEditorProps) => {
const base = `sections.${sectionIndex}.questions.${questionIndex}` as const;
const prompt = useField(form, `${base}.prompt`);
const type = useField(form, `${base}.type`);
const options = useField(form, `${base}.options`);
const scaleMax = useField(form, `${base}.scaleMax`);
const typeProps = muiSelectProps(type);
const optionsProps = muiTextFieldProps(options);
return (
<Box
sx={{
display: "flex",
gap: 1,
alignItems: "flex-start",
py: 1,
borderTop: 1,
borderColor: "divider",
}}
>
<Stack spacing={1} sx={{ flexGrow: 1 }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
<TextField
label={`Question ${questionIndex + 1}`}
size="small"
fullWidth
{...muiTextFieldProps(prompt)}
/>
<FormControl
size="small"
sx={{ minWidth: 170 }}
error={typeProps.error}
>
<InputLabel id={`${base}-type-label`}>Type</InputLabel>
<Select
labelId={`${base}-type-label`}
label="Type"
{...typeProps}
>
{QUESTION_TYPES.map((t) => (
<MenuItem key={t.value} value={t.value}>
{t.label}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
{type.value === "choice" ? (
<TextField
label="Options (comma separated)"
size="small"
fullWidth
{...optionsProps}
helperText={optionsProps.helperText ?? "e.g. Red, Green, Blue"}
/>
) : null}
{type.value === "scale" ? (
<Box>
<Typography variant="caption" color="text.secondary">
Scale range
</Typography>
<Box>
<ToggleButtonGroup
exclusive
size="small"
value={scaleMax.value}
onChange={(_event, next: 5 | 10 | null) => {
if (next !== null) {
scaleMax.setValue(next);
}
}}
>
<ToggleButton value={5}>1 – 5</ToggleButton>
<ToggleButton value={10}>1 – 10</ToggleButton>
</ToggleButtonGroup>
</Box>
</Box>
) : null}
</Stack>
<Box sx={{ whiteSpace: "nowrap", pt: 0.5 }}>
<IconButton
size="small"
aria-label="move question up"
disabled={questionIndex === 0}
onClick={onMoveUp}
>
<ArrowUpwardIcon fontSize="inherit" />
</IconButton>
<IconButton
size="small"
aria-label="move question down"
disabled={questionIndex === count - 1}
onClick={onMoveDown}
>
<ArrowDownwardIcon fontSize="inherit" />
</IconButton>
<IconButton
size="small"
aria-label="remove question"
onClick={onRemove}
>
<DeleteIcon fontSize="inherit" />
</IconButton>
</Box>
</Box>
);
};
type SectionEditorProps = Readonly<{
form: Form<Schema>;
index: number;
count: number;
onRemove: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}>;
const SectionEditor = ({
form,
index,
count,
onRemove,
onMoveUp,
onMoveDown,
}: SectionEditorProps) => {
const title = useField(form, `sections.${index}.title`);
const questions = useFieldArray(
form,
`sections.${index}.questions`,
);
return (
<Card variant="outlined">
<CardContent>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
sx={{ alignItems: { xs: "stretch", sm: "flex-start" } }}
>
<TextField
label={`Section ${index + 1} title`}
size="small"
fullWidth
{...muiTextFieldProps(title)}
/>
<Box sx={{ whiteSpace: "nowrap" }}>
<IconButton
size="small"
aria-label="move section up"
disabled={index === 0}
onClick={onMoveUp}
>
<ArrowUpwardIcon fontSize="inherit" />
</IconButton>
<IconButton
size="small"
aria-label="move section down"
disabled={index === count - 1}
onClick={onMoveDown}
>
<ArrowDownwardIcon fontSize="inherit" />
</IconButton>
<IconButton
size="small"
aria-label="remove section"
onClick={onRemove}
>
<DeleteIcon fontSize="inherit" />
</IconButton>
</Box>
</Stack>
<Box sx={{ mt: 1 }}>
{questions.fields.map((field, questionIndex) => (
<QuestionEditor
key={field.id}
form={form}
sectionIndex={index}
questionIndex={questionIndex}
count={questions.length}
onRemove={() => questions.remove(questionIndex)}
onMoveUp={() => questions.move(questionIndex, questionIndex - 1)}
onMoveDown={() =>
questions.move(questionIndex, questionIndex + 1)
}
/>
))}
</Box>
<Button
size="small"
startIcon={<AddIcon />}
onClick={() => questions.push(NEW_QUESTION)}
>
Add question
</Button>
</CardContent>
</Card>
);
};
const QuestionCount = ({ form }: Readonly<{ form: Form<Schema> }>) => {
const count = useFormSelector(form, (s) =>
s.values.sections.reduce(
(total, section) => total + section.questions.length,
0,
),
);
return (
<Chip
label={`${count} question${count === 1 ? "" : "s"} total`}
size="small"
variant="outlined"
/>
);
};
export const MuiSurveyBuilder = () => {
const form = useForm(schema, {
initialValues: {
title: "Team health check",
sections: [
{
title: "About your week",
questions: [
{ prompt: "How was your week?", type: "text", options: "", scaleMax: 5 },
{
prompt: "Rate your energy level",
type: "scale",
options: "",
scaleMax: 10,
},
],
},
],
},
mode: "onBlur",
});
const title = useField(form, "title");
const sections = useFieldArray(form, "sections");
const rootError = useFormError(form);
const [published, setPublished] = useState(false);
return (
<form
onSubmit={form.handleSubmit(
() => setPublished(true),
(errors) => focusFirstError(errors),
)}
noValidate
>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Nested field arrays: sections each own a question list, reorderable
at both levels. The question type switches its sub-editor, and a
root-level <code>.refine</code> (keyed at <code>""</code>, read via{" "}
<code>useFormError</code>) insists the survey has at least one
question overall — empty every section and publish to see it.
</Typography>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
sx={{ mb: 2, alignItems: { xs: "stretch", sm: "center" } }}
>
<TextField
label="Survey title"
size="small"
sx={{ maxWidth: 360 }}
fullWidth
{...muiTextFieldProps(title)}
/>
<QuestionCount form={form} />
</Stack>
{rootError?.[0] !== undefined ? (
<Alert severity="warning" sx={{ mb: 2 }}>
{rootError[0]}
</Alert>
) : null}
<Stack spacing={2}>
{sections.fields.map((field, index) => (
<SectionEditor
key={field.id}
form={form}
index={index}
count={sections.length}
onRemove={() => sections.remove(index)}
onMoveUp={() => sections.move(index, index - 1)}
onMoveDown={() => sections.move(index, index + 1)}
/>
))}
</Stack>
{sections.error?.[0] !== undefined ? (
<Alert severity="error" sx={{ mt: 2 }}>
{sections.error[0]}
</Alert>
) : null}
<Box sx={{ display: "flex", gap: 1, mt: 2 }}>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={() => sections.push(NEW_SECTION)}
>
Add section
</Button>
<Button variant="contained" type="submit">
Publish survey
</Button>
</Box>
<Snackbar
open={published}
autoHideDuration={4000}
onClose={() => setPublished(false)}
>
<Alert
severity="success"
variant="filled"
onClose={() => setPublished(false)}
>
Survey published.
</Alert>
</Snackbar>
</form>
);
};shadcn/ui demos
The adapter (shadcnAdapter.ts): shadcnAdapter.ts
import type { UseFieldReturn } from "formstand";
import { type ErrorSlice, ariaInvalid } from "../fieldErrors";
// The formstand → shadcn/ui bridge — the Radix half only. shadcn's Input
// and Textarea take native DOM events, so the library's own exported
// builders bind them with nothing extra:
//
// <Input {...textInputProps(field)} />
// <Input {...numberInputProps(field)} />
//
// What needs bridging is the Radix dialect: Checkbox, Switch, Select,
// Slider, and RadioGroup take value-first callbacks (`onCheckedChange`,
// `onValueChange`) and signal "done editing" through close/commit events
// rather than blur. Errors surface as `aria-invalid` (the components style
// themselves off it); render the message with <FieldError field={...} />.
export { ariaInvalid };
export type ShadcnCheckboxProps = Readonly<{
name: string;
checked: boolean;
onCheckedChange: (checked: boolean | "indeterminate") => void;
onBlur: () => void;
"aria-invalid": true | undefined;
}>;
export const shadcnCheckboxProps = <T extends boolean | null | undefined>(
field: UseFieldReturn<T>,
): ShadcnCheckboxProps => ({
name: field.path,
checked: field.value ?? false,
// Radix reports "indeterminate" only for tri-state checkboxes; a boolean
// field never sets that state, so it reads as unchecked.
onCheckedChange: (checked) => field.setValue((checked === true) as T),
onBlur: field.onBlur,
"aria-invalid": ariaInvalid(field),
});
export type ShadcnSwitchProps = Readonly<{
name: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
onBlur: () => void;
}>;
export const shadcnSwitchProps = <T extends boolean | null | undefined>(
field: UseFieldReturn<T>,
): ShadcnSwitchProps => ({
name: field.path,
checked: field.value ?? false,
onCheckedChange: (checked) => field.setValue(checked as T),
onBlur: field.onBlur,
});
// Spread onto <Select> (the Radix Root); pair with your own SelectTrigger
// (give the trigger `aria-invalid={ariaInvalid(field)}` for error styling).
// Radix has no blur event on the root — closing the dropdown is the "done
// editing" signal, so it maps to the field's blur trigger and onBlur-mode
// forms validate when the menu closes.
export type ShadcnSelectProps = Readonly<{
name: string;
value: string;
onValueChange: (value: string) => void;
onOpenChange: (open: boolean) => void;
}>;
export const shadcnSelectProps = <T extends string | null | undefined>(
field: UseFieldReturn<T>,
): ShadcnSelectProps => ({
name: field.path,
// Radix shows the placeholder for "" — and never reports "" back through
// onValueChange (items can't carry an empty value), so the round-trip is
// safe for fields that start empty.
value: field.value ?? "",
onValueChange: (value) => field.setValue(value as T),
onOpenChange: (open) => {
if (!open) field.onBlur();
},
});
export type ShadcnRadioGroupProps = Readonly<{
name: string;
value: string;
onValueChange: (value: string) => void;
"aria-invalid": true | undefined;
}>;
export const shadcnRadioGroupProps = <T extends string | null | undefined>(
field: UseFieldReturn<T>,
): ShadcnRadioGroupProps => ({
name: field.path,
value: field.value ?? "",
onValueChange: (value) => field.setValue(value as T),
"aria-invalid": ariaInvalid(field),
});
// Radix sliders speak number[] (multi-thumb); a single-number field binds
// to a one-element array. onValueChange fires continuously while dragging,
// so validation waits for onValueCommit (mapped to the blur trigger) —
// onBlur-mode forms don't validate sixty times a second mid-drag.
export type ShadcnSliderProps = Readonly<{
name: string;
// Mutable tuple on purpose: Radix types `value` as number[], and a
// readonly array wouldn't be assignable to it.
value: [number];
onValueChange: (value: readonly number[]) => void;
onValueCommit: () => void;
}>;
export const shadcnSliderProps = <T extends number>(
field: UseFieldReturn<T>,
): ShadcnSliderProps => ({
name: field.path,
value: [field.value],
onValueChange: (value) => field.setValue((value[0] ?? field.value) as T),
onValueCommit: () => field.onBlur(),
});
// Re-exported so demo code can keep a single adapter import even though the
// slice type lives in the shared module.
export type { ErrorSlice };Shared error helpers (used by both adapters): fieldErrors.ts
// The one definition of "this field slice has an error", shared by every
// adapter in the playground (MUI, shadcn) and the shadcn FieldError line —
// four hand-synced copies of this predicate is how one adapter ends up
// styling stale errors while the others move on.
export type ErrorSlice = Readonly<{ error: readonly string[] | undefined }>;
export const hasError = (field: ErrorSlice): boolean =>
field.error !== undefined && field.error.length > 0;
export const firstError = (field: ErrorSlice): string | undefined =>
field.error?.[0];
// aria-invalid only when true — `aria-invalid="false"` is noise for screen
// readers and would make every pristine control style-relevant.
export const ariaInvalid = (field: ErrorSlice): true | undefined =>
hasError(field) ? true : undefined;Error line: FieldError.tsx
import { type ErrorSlice, firstError } from "../fieldErrors";
export type FieldErrorProps = Readonly<{
field: ErrorSlice;
}>;
// The shadcn FormMessage pattern without the Form context machinery:
// formstand's useField already carries the error, so the message renders
// straight off the field slice.
export const FieldError = ({ field }: FieldErrorProps) =>
firstError(field) !== undefined ? (
<p data-slot="field-error" className="text-sm text-destructive">
{firstError(field)}
</p>
) : null;shadcn: Signup: ShadcnSignupForm.tsx
import { useState } from "react";
import {
textInputProps,
useField,
useForm,
useIsSubmitting,
} from "formstand";
import { z } from "zod";
import { FieldError } from "./FieldError";
import { shadcnCheckboxProps } from "./shadcnAdapter";
import { Button } from "./ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./ui/card";
import { Checkbox } from "./ui/checkbox";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
const TAKEN = new Set(["tim", "admin", "root"]);
const fakeAvailabilityCheck = (name: string): Promise<boolean> =>
new Promise((resolve) =>
setTimeout(() => resolve(!TAKEN.has(name.toLowerCase())), 500),
);
const schema = z
.object({
// The async refine makes the whole schema async; formstand validates
// just this field's subschema on edits, so typing in the password box
// never fires the availability check.
username: z
.string()
.min(3, "3+ characters")
.refine(fakeAvailabilityCheck, "that username is taken"),
email: z.email("valid email required"),
password: z.string().min(8, "8+ characters"),
confirm: z.string(),
terms: z.boolean().refine((v) => v, "you must accept the terms"),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirm) {
ctx.addIssue({
code: "custom",
message: "passwords must match",
path: ["confirm"],
});
}
});
export const ShadcnSignupForm = () => {
const form = useForm(schema, {
initialValues: {
username: "",
email: "",
password: "",
confirm: "",
terms: false,
},
mode: "onBlur",
});
const username = useField(form, "username");
const email = useField(form, "email");
const password = useField(form, "password");
const confirm = useField(form, "confirm");
const terms = useField(form, "terms");
const isSubmitting = useIsSubmitting(form);
const [created, setCreated] = useState<string | null>(null);
return (
<Card className="max-w-md">
<CardHeader>
<CardTitle>Create your account</CardTitle>
<CardDescription>
onBlur mode — try <code>tim</code> as the username: the async
availability check runs against just that field's subschema.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="signup-username">Username</Label>
<Input id="signup-username" {...textInputProps(username)} />
{username.isValidating ? (
<p className="text-sm text-muted-foreground">
checking availability…
</p>
) : (
<FieldError field={username} />
)}
</div>
<div className="grid gap-2">
<Label htmlFor="signup-email">Email</Label>
<Input
id="signup-email"
placeholder="you@example.com"
{...textInputProps(email)}
/>
<FieldError field={email} />
</div>
<div className="grid gap-2">
<Label htmlFor="signup-password">Password</Label>
<Input
id="signup-password"
type="password"
{...textInputProps(password)}
/>
<FieldError field={password} />
</div>
<div className="grid gap-2">
<Label htmlFor="signup-confirm">Confirm password</Label>
<Input
id="signup-confirm"
type="password"
{...textInputProps(confirm)}
/>
<FieldError field={confirm} />
</div>
<div className="grid gap-2">
<div className="flex items-center gap-2">
<Checkbox id="signup-terms" {...shadcnCheckboxProps(terms)} />
<Label htmlFor="signup-terms">
I accept the terms of service
</Label>
</div>
<FieldError field={terms} />
</div>
</CardContent>
<CardFooter className="flex-col items-start gap-2">
<Button
type="submit"
disabled={isSubmitting}
onClick={() =>
void form.handleSubmit((data) => {
setCreated(data.username);
})()
}
>
{isSubmitting ? "Creating…" : "Create account"}
</Button>
{created !== null ? (
<p className="text-sm text-muted-foreground">
welcome, <span className="text-foreground">{created}</span> —
submit resolved <code>kind: "valid"</code>.
</p>
) : null}
</CardFooter>
</Card>
);
};shadcn: Checkout: ShadcnCheckoutForm.tsx
import { useState } from "react";
import {
numberInputProps,
textInputProps,
useField,
useForm,
useFormSelector,
} from "formstand";
import { z } from "zod";
import { FieldError } from "./FieldError";
import { ariaInvalid, shadcnRadioGroupProps, shadcnSelectProps } from "./shadcnAdapter";
import { Button } from "./ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./ui/card";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
import { Textarea } from "./ui/textarea";
const COUNTRIES = [
{ value: "us", label: "United States" },
{ value: "ca", label: "Canada" },
{ value: "de", label: "Germany" },
{ value: "jp", label: "Japan" },
] as const;
const SHIPPING = [
{ value: "standard", label: "Standard (5–7 days)", price: 0 },
{ value: "express", label: "Express (2 days)", price: 9 },
{ value: "overnight", label: "Overnight", price: 24 },
] as const;
const UNIT_PRICE = 19;
const schema = z.object({
country: z.enum(["us", "ca", "de", "jp"], "pick a country"),
shipping: z.enum(["standard", "express", "overnight"]),
quantity: z
.number("quantity required")
.int("whole units only")
.min(1, "at least 1")
.max(99, "99 max"),
// "No note" is null, not "" — clearing the textarea round-trips to null
// via field.emptyValue.
giftNote: z.string().max(120, "120 chars max").nullable(),
});
export const ShadcnCheckoutForm = () => {
const form = useForm(schema, {
initialValues: {
// The select starts empty: Radix shows the placeholder for "", and
// submit surfaces the enum's "pick a country" message.
country: "" as never,
shipping: "standard",
quantity: 1,
giftNote: null,
},
mode: "onChange",
});
const country = useField(form, "country");
const shipping = useField(form, "shipping");
const quantity = useField(form, "quantity");
const giftNote = useField(form, "giftNote");
const [placed, setPlaced] = useState(false);
// Derived money line — recomputes only when the inputs it reads change.
const total = useFormSelector(form, (state) => {
const rate =
SHIPPING.find((s) => s.value === state.values.shipping)?.price ?? 0;
return (state.values.quantity ?? 0) * UNIT_PRICE + rate;
});
return (
<Card className="max-w-md">
<CardHeader>
<CardTitle>Checkout</CardTitle>
<CardDescription>
Radix Select and RadioGroup speak <code>onValueChange</code>, not
DOM events — the adapter maps closing the dropdown to the blur
trigger.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label>Country</Label>
<Select {...shadcnSelectProps(country)}>
<SelectTrigger
className="w-full"
aria-invalid={ariaInvalid(country)}
>
<SelectValue placeholder="Select a country" />
</SelectTrigger>
<SelectContent>
{COUNTRIES.map((c) => (
<SelectItem key={c.value} value={c.value}>
{c.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError field={country} />
</div>
<div className="grid gap-2">
<Label>Shipping</Label>
<RadioGroup {...shadcnRadioGroupProps(shipping)}>
{SHIPPING.map((option) => (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem
id={`ship-${option.value}`}
value={option.value}
/>
<Label
htmlFor={`ship-${option.value}`}
className="font-normal"
>
{option.label}
<span className="text-muted-foreground">
{option.price === 0 ? "free" : `$${option.price}`}
</span>
</Label>
</div>
))}
</RadioGroup>
</div>
<div className="grid gap-2">
<Label htmlFor="checkout-qty">Quantity (${UNIT_PRICE} each)</Label>
<Input
id="checkout-qty"
className="w-24"
{...numberInputProps(quantity)}
/>
<FieldError field={quantity} />
</div>
<div className="grid gap-2">
<Label htmlFor="checkout-note">Gift note (optional)</Label>
<Textarea
id="checkout-note"
placeholder="Happy birthday!"
{...textInputProps(giftNote)}
/>
<p className="text-sm text-muted-foreground">
nullable field — store value is{" "}
<code>{giftNote.value === null ? "null" : "a string"}</code>;
clearing it writes null back, not "".
</p>
<FieldError field={giftNote} />
</div>
</CardContent>
<CardFooter className="justify-between">
<p className="text-sm">
Total: <span className="font-semibold">${total}</span>
</p>
<Button
onClick={() =>
void form.handleSubmit(() => {
setPlaced(true);
})()
}
>
Place order
</Button>
</CardFooter>
{placed ? (
<p className="px-4 pb-2 text-sm text-muted-foreground">
order placed — thanks!
</p>
) : null}
</Card>
);
};shadcn: Settings: ShadcnSettingsForm.tsx
import { useState } from "react";
import {
type Form,
textInputProps,
useField,
useForm,
useFormSelectorShallow,
useIsDirty,
useIsValid,
} from "formstand";
import { z } from "zod";
import { FieldError } from "./FieldError";
import {
shadcnSelectProps,
shadcnSliderProps,
shadcnSwitchProps,
} from "./shadcnAdapter";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
import { Slider } from "./ui/slider";
import { Switch } from "./ui/switch";
const THEMES = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
] as const;
const schema = z.object({
displayName: z.string().min(1, "display name required"),
theme: z.enum(["system", "light", "dark"]),
volume: z.number().min(0).max(100),
notifications: z.object({
email: z.boolean(),
push: z.boolean(),
digest: z.boolean(),
}),
});
type Schema = typeof schema;
// The badges row gets its own shallow selector: dirtyFields() builds a fresh
// array per call, so it must be the selector's direct result (compared
// element-wise by useShallow), never a property of a composite selector
// object.
const DirtyBadges = ({ form }: Readonly<{ form: Form<Schema> }>) => {
const dirtyPaths = useFormSelectorShallow(form, () => form.dirtyFields());
return (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm text-muted-foreground">
Unsaved changes ({dirtyPaths.length}):
</span>
{dirtyPaths.length === 0 ? (
<span className="text-sm text-muted-foreground">none</span>
) : (
dirtyPaths.map((path) => <Badge key={path}>{path}</Badge>)
)}
</div>
);
};
export const ShadcnSettingsForm = () => {
const form = useForm(schema, {
initialValues: {
displayName: "Tim",
theme: "system",
volume: 40,
notifications: { email: true, push: false, digest: true },
},
mode: "onChange",
});
const displayName = useField(form, "displayName");
const theme = useField(form, "theme");
const volume = useField(form, "volume");
const emailNotif = useField(form, "notifications.email");
const pushNotif = useField(form, "notifications.push");
const digestNotif = useField(form, "notifications.digest");
const isDirty = useIsDirty(form);
const isValid = useIsValid(form);
const [saved, setSaved] = useState(false);
return (
<div className="grid max-w-md gap-4">
<p className="text-sm text-muted-foreground">
Save is gated on <code>useIsDirty && useIsValid</code>; the badges are{" "}
<code>form.dirtyFields()</code> live. Saving calls{" "}
<code>adoptValues</code> (rebase, not reset) — Discard calls{" "}
<code>reset()</code> back to the last saved state.
</p>
<DirtyBadges form={form} />
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="settings-name">Display name</Label>
<Input id="settings-name" {...textInputProps(displayName)} />
<FieldError field={displayName} />
</div>
<div className="grid gap-2">
<Label>Theme</Label>
<Select {...shadcnSelectProps(theme)}>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THEMES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
{/* No htmlFor: Radix Slider's root is a <span> (not labelable),
so the accessible name goes to the thumb via aria-label. */}
<Label>
Notification volume
<span className="text-muted-foreground">{volume.value}%</span>
</Label>
<Slider
aria-label="Notification volume"
min={0}
max={100}
step={1}
{...shadcnSliderProps(volume)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>
Radix switches — <code>onCheckedChange</code> via the adapter.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
<div className="flex items-center justify-between">
<Label htmlFor="settings-email">Email notifications</Label>
<Switch id="settings-email" {...shadcnSwitchProps(emailNotif)} />
</div>
<div className="flex items-center justify-between">
<Label htmlFor="settings-push">Push notifications</Label>
<Switch id="settings-push" {...shadcnSwitchProps(pushNotif)} />
</div>
<div className="flex items-center justify-between">
<Label htmlFor="settings-digest">Weekly digest</Label>
<Switch id="settings-digest" {...shadcnSwitchProps(digestNotif)} />
</div>
</CardContent>
</Card>
<div className="flex items-center gap-2">
<Button
disabled={!isDirty || !isValid}
onClick={() =>
void form.handleSubmit(() => {
form.adoptValues(form.getState().values);
setSaved(true);
})()
}
>
Save settings
</Button>
<Button
variant="outline"
disabled={!isDirty}
onClick={() => form.reset()}
>
Discard changes
</Button>
{saved && !isDirty ? (
<span className="text-sm text-muted-foreground">
saved — the badges are gone until the next edit.
</span>
) : null}
</div>
</div>
);
};shadcn: Team: ShadcnTeamForm.tsx
import { memo, useState } from "react";
import { ArrowUpIcon, PlusIcon, Trash2Icon } from "lucide-react";
import {
type Form,
textInputProps,
useField,
useFieldArray,
useForm,
} from "formstand";
import { z } from "zod";
import { FieldError } from "./FieldError";
import { shadcnSelectProps } from "./shadcnAdapter";
import { Button } from "./ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./ui/card";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
const ROLES = [
{ value: "engineer", label: "Engineer" },
{ value: "designer", label: "Designer" },
{ value: "manager", label: "Manager" },
] as const;
const memberSchema = z.object({
name: z.string().min(1, "name required"),
email: z.email("valid email required"),
role: z.enum(["engineer", "designer", "manager"]),
});
const schema = z.object({
teamName: z.string().min(2, "2+ characters"),
members: z
.array(memberSchema)
.min(1, "add at least one member")
// Cross-row rule: an email is a duplicate when an earlier row already
// holds it (findIndex returns the FIRST match), so the issue lands on
// the row that introduced the copy and renders under its email field.
.superRefine((members, ctx) => {
members
.map((member, index) => ({ key: member.email.toLowerCase(), index }))
.filter(
({ key, index }) =>
key !== "" &&
members.findIndex((m) => m.email.toLowerCase() === key) !== index,
)
.forEach(({ index }) => {
ctx.addIssue({
code: "custom",
message: "duplicate email",
path: [index, "email"],
});
});
}),
});
type Schema = typeof schema;
type MemberRowProps = Readonly<{
form: Form<Schema>;
index: number;
move: (from: number, to: number) => void;
remove: (index: number) => void;
}>;
// One memoized component per row: the per-row useField subscriptions keep
// each row's reads self-contained, and memo (every prop here is stable —
// useFieldArray's move/remove are useCallback'd) is what actually stops
// keystrokes in one row, or in the team name, from re-rendering the others.
// Without memo the parent re-renders on any member edit (its useFieldArray
// slice changes identity) and would take every row with it.
const MemberRow = memo(({ form, index, move, remove }: MemberRowProps) => {
const name = useField(form, `members.${index}.name`);
const email = useField(form, `members.${index}.email`);
const role = useField(form, `members.${index}.role`);
return (
<div className="grid grid-cols-[1fr_1.3fr_auto_auto_auto] items-start gap-2">
<div className="grid gap-1">
<Input placeholder="Name" {...textInputProps(name)} />
<FieldError field={name} />
</div>
<div className="grid gap-1">
<Input placeholder="email@team.dev" {...textInputProps(email)} />
<FieldError field={email} />
</div>
<Select {...shadcnSelectProps(role)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROLES.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
aria-label="Move up"
disabled={index === 0}
onClick={() => move(index, index - 1)}
>
<ArrowUpIcon />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Remove member"
onClick={() => remove(index)}
>
<Trash2Icon />
</Button>
</div>
);
});
export const ShadcnTeamForm = () => {
const form = useForm(schema, {
initialValues: {
teamName: "Forms Guild",
members: [
{ name: "Tim", email: "tim@team.dev", role: "engineer" },
{ name: "Ada", email: "ada@team.dev", role: "manager" },
],
},
mode: "onChange",
});
const teamName = useField(form, "teamName");
const members = useFieldArray(form, "members");
const [savedCount, setSavedCount] = useState<number | null>(null);
return (
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Team roster</CardTitle>
<CardDescription>
<code>useFieldArray</code> rows with stable ids — reorder after
editing and the drafts follow their rows. Duplicate emails are a
cross-row <code>superRefine</code>.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid max-w-xs gap-2">
<Label htmlFor="team-name">Team name</Label>
<Input id="team-name" {...textInputProps(teamName)} />
<FieldError field={teamName} />
</div>
<div className="grid gap-2">
<Label>Members</Label>
{members.fields.map((entry, index) => (
<MemberRow
key={entry.id}
form={form}
index={index}
move={members.move}
remove={members.remove}
/>
))}
<FieldError field={members} />
</div>
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() =>
members.push({ name: "", email: "", role: "engineer" })
}
>
<PlusIcon />
Add member
</Button>
</CardContent>
<CardFooter className="gap-3">
<Button
onClick={() =>
void form.handleSubmit((data) => {
setSavedCount(data.members.length);
})()
}
>
Save roster
</Button>
{savedCount !== null ? (
<span className="text-sm text-muted-foreground">
saved {savedCount} member{savedCount === 1 ? "" : "s"}.
</span>
) : null}
</CardFooter>
</Card>
);
};Generated output, as a living example
The Onboarding (CLI output) tab is not hand-written: CI runs formstand-gen schema.ts --layout module --ui mui --sections panel --columns 2 against the Onboarding schema and fails if the committed demo differs from what the current CLI emits, so the tab is always the generator's real, untouched output (examples/src/generated). Compare it against the hand-built MUI Onboarding tab to see exactly what the generator gives you versus what you might grow it into.
The Gen: Chakra UI, Gen: Mantine, and Gen: Ant Design tabs run the SAME Onboarding schema through --ui chakra, --ui mantine, and --ui antd with the same section chrome, so flipping between the four tabs compares what each kit target emits, with one deliberate second axis: the mui tab is --layout module (it doubles as the module-layout showcase), while the three kit tabs are single-file output. They're regenerated and drift-checked by CI the same way, and each supported kit major is typechecked against its real declarations by the CLI's version matrix (cli/matrix/).
Schema builder: the generator in your browser
The Schema builder tab (direct link) runs the real formstand-gen emitters in the browser: design fields and sections in a formstand form and the generated files (all the --ui / --layout / --sections / --columns combinations) regenerate on every keystroke. Everything downstream of the CLI's IR is a pure string builder, so the playground imports the emitters straight from the CLI's source, and the output is exactly what npx formstand-gen writes to disk. Every playground tab is directly linkable the same way: examples/#/<tab-name> in kebab-case.
Already have a schema? Import code… opens a modal that takes a TypeScript type or a zod schema (paste it, or pick a .ts file) and generates the form from it. A pasted type is read by the same compiler-free parser the CLI's --type mode uses; a pasted z.object(...) is evaluated in your browser against the bundled zod and walked by the real fromZod, so both land on the identical IR → emitters path as build mode.
Running locally
git clone https://github.com/Scrumrot/formstand
cd formstand && npm install
npm run examplesThe playground aliases formstand to the library source, so edits to src/ hot-reload straight into the demos.