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…) — no provider, 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 | A multi-step form gating each step on its own fields with validateFields | 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, 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
Five demos bind formstand to Material UI through a ~60-line adapter — muiAdapter.ts turns a useField result into spreadable props for TextField, Select, Switch, and Slider, reusing the library's exported parseNumberText/numberToInputText rules. Nothing MUI-specific lives in the library; this is the pattern to copy for any third-party UI kit. All five 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 |
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/numberInputProps bind them with nothing extra — 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/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 actual source file at build time, so it can never drift from what the playground runs. Two lines in each demo are playground harness, not library API: import { useDemoForm } from "../demo/DemoShell" and the useDemoForm(form) call register the demo's form with the playground shell, which is what powers the View state panel (rendered by StateDump.tsx below). Delete those two lines when copying a demo into your own project — everything else is plain formstand.
Basic + modes — BasicForm.tsx
import { useField, useForm, useFormSelector, type ValidationMode } from "formstand";
import { useDemoForm } from "../demo/DemoShell";
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: "" },
});
useDemoForm(form);
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 { useDemoForm } from "../demo/DemoShell";
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,
});
useDemoForm(form);
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 { useDemoForm } from "../demo/DemoShell";
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>Tagline (custom input via textInputProps)</label>
<input {...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",
});
useDemoForm(form);
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";
import { useDemoForm } from "../demo/DemoShell";
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 = () => {
useDemoForm(invoiceForm);
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>Customer</label>
<input {...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>{label}</label>
<input {...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 { useDemoForm } from "../../demo/DemoShell";
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 = () => {
useDemoForm(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 { useDemoForm } from "../demo/DemoShell";
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: "" },
},
});
useDemoForm(form);
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>Name</label>
<input
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>Street</label>
<input
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>City</label>
<input
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>Zip</label>
<input
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 { useDemoForm } from "../demo/DemoShell";
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",
});
useDemoForm(form);
// 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}
/>
))}
<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 { useDemoForm } from "../demo/DemoShell";
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",
});
useDemoForm(form);
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>Username</label>
<input {...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 { useState } from "react";
import {
CheckboxField,
NumberField,
SelectField,
TextField,
useForm,
} from "formstand";
import { useDemoForm } from "../demo/DemoShell";
import { z } from "zod";
const schema = z.object({
email: z.email("valid email required"),
password: z.string().min(8, "min 8 chars"),
firstName: z.string().min(1, "required"),
lastName: z.string().min(1, "required"),
age: z.int().min(13, "must be 13+"),
newsletter: z.boolean(),
theme: z.enum(["light", "dark"]),
});
const STEP_PATHS = {
0: ["email", "password"],
1: ["firstName", "lastName", "age"],
2: ["newsletter", "theme"],
} as const;
export const WizardForm = () => {
const form = useForm(schema, {
initialValues: {
email: "",
password: "",
firstName: "",
lastName: "",
age: 0,
newsletter: false,
theme: "light",
},
mode: "onBlur",
});
useDemoForm(form);
const [step, setStep] = useState<0 | 1 | 2>(0);
const handleNext = () => {
// Sync schema, so validateFields settles synchronously; an async schema
// would hand back the Promise<boolean> instead.
if (form.validateFields(STEP_PATHS[step]) === true) {
setStep((s) => (s + 1) as 0 | 1 | 2);
}
};
return (
<form
onSubmit={form.handleSubmit((data) => {
window.alert(`signed up: ${JSON.stringify(data, null, 2)}`);
})}
>
<div className="subtitle">Step {step + 1} of 3</div>
{step === 0 ? (
<>
<TextField form={form} path="email" label="Email" type="email" />
<TextField
form={form}
path="password"
label="Password"
type="password"
/>
</>
) : null}
{step === 1 ? (
<>
<TextField form={form} path="firstName" label="First name" />
<TextField form={form} path="lastName" label="Last name" />
<NumberField form={form} path="age" label="Age" />
</>
) : null}
{step === 2 ? (
<>
<CheckboxField
form={form}
path="newsletter"
label="Subscribe to newsletter"
/>
<SelectField
form={form}
path="theme"
label="Theme"
options={[
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]}
/>
</>
) : null}
<div className="row" style={{ marginTop: 16 }}>
{step > 0 ? (
<button
className="secondary"
type="button"
onClick={() => setStep((s) => (s - 1) as 0 | 1 | 2)}
>
Back
</button>
) : null}
{step < 2 ? (
<button className="primary" type="button" onClick={handleNext}>
Next
</button>
) : (
<button className="primary" type="submit">
Submit
</button>
)}
</div>
</form>
);
};Conditional — ConditionalForm.tsx
import { SelectField, TextField, useForm, useFormSelector } from "formstand";
import { useDemoForm } from "../demo/DemoShell";
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",
});
useDemoForm(form);
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 { useDemoForm } from "../demo/DemoShell";
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
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",
});
useDemoForm(form);
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>Customer</label>
<input {...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 { useDemoForm } from "../demo/DemoShell";
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
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",
});
useDemoForm(form);
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 { useDemoForm } from "../demo/DemoShell";
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",
});
useDemoForm(form);
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,
useFormSelector,
useFormSelectorShallow,
useIsDirty,
} from "formstand";
import { useDemoForm } from "../demo/DemoShell";
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" });
useDemoForm(form);
const title = useField(form, "title");
const body = useField(form, "body");
const values = useFormSelector(form, (s) => s.values);
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>Title</label>
<input {...textInputProps(title)} />
<span className="error">{title.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label>Body</label>
<textarea
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 { useDemoForm } from "../demo/DemoShell";
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",
});
useDemoForm(form);
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>Country</label>
<select
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>Region</label>
<select
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>City</label>
<input {...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 { useDemoForm } from "../demo/DemoShell";
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",
});
useDemoForm(form);
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>Display name</label>
<input {...textInputProps(displayName)} />
<span className="error">{displayName.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label>Bio</label>
<textarea
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 ? (
<div className="error" style={{ marginBottom: 12 }}>
Server error: {serverError}
</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 { useDemoForm } from "../demo/DemoShell";
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",
});
useDemoForm(form);
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>Caption</label>
<input
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>File</label>
<input
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 { useDemoForm } from "../demo/DemoShell";
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",
});
useDemoForm(form);
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>First name</label>
<input {...textInputProps(firstName)} />
<span className="error">{firstName.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label>Last name</label>
<input {...textInputProps(lastName)} />
<span className="error">{lastName.error?.[0] ?? " "}</span>
</div>
<div className="field">
<label>Display name (derived)</label>
<input 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 { useDemoForm } from "../demo/DemoShell";
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",
});
useDemoForm(form);
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>Title</label>
<input
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>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
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";
import { useDemoForm } from "../demo/DemoShell";
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.)
useDemoForm(form);
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>Form size:</label>
<select
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,
numberToInputText,
parseNumberText,
} from "formstand";
import { firstError, hasError } from "../fieldErrors";
// The formstand → Material UI bridge. Each builder takes a `useField` result
// and returns a spreadable props object for the matching MUI component —
// the same shape as the library's own textInputProps/numberInputProps, but
// speaking MUI's dialect: validity as an `error` boolean plus a `helperText`
// line instead of `aria-invalid` (MUI wires the aria attributes itself).
// This ~60-line 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 MuiNumberFieldProps = Readonly<{
type: "number";
name: string;
value: string;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onBlur: () => void;
error: boolean;
helperText: string | undefined;
}>;
export const muiNumberFieldProps = <T extends number | null | undefined>(
field: UseFieldReturn<T>,
): MuiNumberFieldProps => ({
type: "number",
name: field.path,
value: numberToInputText(field.value),
onChange: (event) => {
const parsed = parseNumberText(event.target.value);
field.setValue(
(parsed.kind === "number" ? parsed.value : field.emptyValue) as T,
);
},
onBlur: field.onBlur,
error: hasError(field),
helperText: firstError(field),
});
// 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,
useFormSelector,
} from "formstand";
import { z } from "zod";
import { useDemoForm } from "../demo/DemoShell";
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
// wizard shell doesn't re-render per keystroke on earlier steps.
const ReviewStep = ({ form }: StepProps) => {
const values = useFormSelector(form, (s) => s.values);
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",
});
useDemoForm(form);
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; await also
// covers the Promise<boolean> an async schema would hand back.
const ok = await Promise.resolve(form.validateFields(paths));
if (ok) {
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 { useDemoForm } from "../demo/DemoShell";
import {
muiNumberFieldProps,
muiSliderProps,
muiSwitchProps,
muiTextFieldProps,
} 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",
});
useDemoForm(form);
// 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 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
{...muiNumberFieldProps(years)}
slotProps={{ htmlInput: { min: 0, max: 50, step: 1 } }}
/>
</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,
focusFirstError,
useField,
useFieldArray,
useForm,
useFormSelector,
useIsDirty,
useIsSubmitting,
} from "formstand";
import { z } from "zod";
import { useDemoForm } from "../demo/DemoShell";
import {
muiNumberFieldProps,
muiSelectProps,
muiTextFieldProps,
} 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 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
{...muiNumberFieldProps(quantity)}
slotProps={{ htmlInput: { min: 1, step: 1 } }}
/>
</TableCell>
<TableCell sx={{ width: 110 }}>
<TextField
variant="standard"
fullWidth
{...muiNumberFieldProps(unitPrice)}
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
/>
</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",
});
useDemoForm(form);
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)}
/>
<TableContainer>
<Table size="small">
<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={() => items.push(NEW_ITEM)}
>
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 { useDemoForm } from "../demo/DemoShell";
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",
});
useDemoForm(form);
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 { useDemoForm } from "../demo/DemoShell";
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="row" spacing={1} sx={{ alignItems: "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",
});
useDemoForm(form);
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="row"
spacing={1}
sx={{ mb: 2, alignItems: "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 { useDemoForm } from "../demo/DemoShell";
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",
});
useDemoForm(form);
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 { useDemoForm } from "../demo/DemoShell";
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",
});
useDemoForm(form);
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 { useDemoForm } from "../demo/DemoShell";
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",
});
useDemoForm(form);
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 { useDemoForm } from "../demo/DemoShell";
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",
});
useDemoForm(form);
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.
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 — 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.