API reference
This page covers the full exported surface of formstand: the Form instance methods, creation options and result types, every React hook and component, the prop builders, the exported core utilities, and the complete type list. For narrative guides, start at Getting started.
createForm(schema, options)
Creates a Form<TSchema> instance. React users typically use useForm instead, which wraps this.
const form = createForm(schema, {
initialValues, // z.input<TSchema> — required
mode: "onBlur", // ValidationMode, default "onBlur"
reValidateMode: "onChange", // ValidationMode used once submitCount > 0
validateOnMount: false, // run a validation pass at creation
});CreateFormOptions<TSchema>
| Option | Type | Notes |
|---|---|---|
initialValues | z.input<TSchema> | required; also the baseline for dirtiness |
mode | ValidationMode | "onChange" | "onBlur" | "onSubmit" | "onTouched" | "all", default "onBlur" |
reValidateMode | ValidationMode | mode used after the first submit attempt, default "onChange" |
validateOnMount | boolean | validate at creation so useIsValid reflects the initial values (async schemas validate in the background) |
Form<TSchema> methods
| Method | Notes |
|---|---|
schema / store | the schema and the underlying zustand store (getState / getInitialState / subscribe) |
getState() / subscribe(listener) | direct store access; listener(state, prev) fires on every change |
setValue(path, value) | updates one field. Dirtiness is derived, not stored: a field reads as dirty while its value differs structurally from initialValues at that path (arrays/plain objects compare deep, Dates by timestamp, Object.is otherwise) |
setValues(next) | replace the entire values object; server errors release only where a value slice actually changed |
setTouched(path, touched?) | marks a path touched (default true) |
setSubmitting(value) | manually set the isSubmitting flag |
setError(path, errors) / setErrors(map) / clearErrors(path?) | the app-owned server error channel (state.serverErrors) — validation never touches it; setError accepts a single string or an array; clearErrors(path) clears both channels at the path and its descendants (clearErrors("") clears just the root entry; clearErrors() clears everything); setErrors replaces the whole server channel |
setMode(mode) / setReValidateMode(mode) | switch validation modes at runtime |
reset(nextInitial?, options?) | reset to initial; optional partial overrides (shallow-merged for record roots, replaced wholesale otherwise) and ResetOptions (no keepDirty — dirtiness derives from values vs initial, and reset makes them equal) |
resetField(path) | reset one field to its initial value, clearing its (and descendants') error/touched state |
adoptValues(values) | mid-session rebase: replaces values + initialValues and clears errors and in-flight validation flags (isValidating/isValidatingForm — the rebase disowns in-flight passes), but preserves interaction state (touched, submitCount, isSubmitting, mode). Use reset() for a full wipe |
updateState(updater) | atomic multi-field patch; errors is derived from schemaErrors/serverErrors, so patch the channels — the patch type omits errors entirely, and a plain-JS errors patch is warned about and ignored |
validate() / validateField(path) / validateFields(paths) | sync validation; on an async schema they transparently start the async pass instead (validate/validateField return { kind: "pending", promise }, validateFields returns the Promise<boolean> itself) |
validateAsync() / validateFieldAsync(path) / validateFieldsAsync(paths) | async; supports async .refine |
submit(onValid, onInvalid?, { force? }) | full submit flow, returns Promise<SubmitResult>; resolves { kind: "valid", data }, { kind: "invalid", errors } (errored fields are also marked touched), { kind: "skipped" } when another submit is in flight and force isn't set, or { kind: "error", error } when onValid throws/rejects (submit resolves instead of rejecting, so handleSubmit never leaves an unhandled rejection) |
handleSubmit(onValid, onInvalid?, options?) | returns an event handler that calls preventDefault() and runs submit |
getField(path) | typed one-shot value read |
getFieldState(path) | typed one-shot read of a field's full slice — a FieldSnapshot |
watchField(path, listener) | subscribe to one field's FieldSnapshot; returns an unsubscribe function |
watchValue(path, listener) | subscribe to one path's value (Object.is-compared); listener(next, prev) |
watchValues(listener) | subscribe to the whole values object; listener(values, prev) fires on any value change. (The name is watchValue + "s" as in "all the values" — it is not a multi-path watchValue; a future multi-path watcher would be a new API) |
diff() / dirtyFields() | PATCH-style helpers, derived by comparing values against initialValues: minimal divergent paths (objects recurse to the changed leaves; arrays report their base path). Reverting a field drops it |
snapshot() / restore(snap) | full state capture/restore for undo/rollback; restore re-derives the merged errors map from the snapshot's channels, and clears the transient in-flight flags (isValidating/isValidatingForm) instead of restoring them — in-flight state is owned by live passes, never by snapshots |
arrayPush(path, item) / arrayRemove(path, index) / arrayInsert(path, index, item) / arrayMove(path, from, to) / arraySwap(path, a, b) | array ops with meta-key re-keying (errors/touched/server verdicts follow their rows); out-of-range or non-integer indices are refused with a warning |
All paths on the imperative surface are FieldPath-typed; runtime-built strings need a cast — see Typed paths.
The positional submit(onValid, onInvalid?, options?) is the committed shape (no options-object overload is planned): when you need options without an invalid handler, pass undefined in its slot —
await form.submit(onValid, undefined, { force: true });Result and option types
SubmitResult<TOutput>
type SubmitResult<TOutput> =
| { kind: "valid"; data: TOutput } // validation passed, onValid ran to completion
| { kind: "invalid"; errors: ErrorMap } // onInvalid ran, errors written, fields marked touched
| { kind: "skipped" } // another submit was in flight and force wasn't set
| { kind: "error"; error: unknown }; // onValid threw or rejected — submit resolves with the
// thrown value instead of rejecting; no error state is
// written (surface it yourself, e.g. via setError)ResetOptions
type ResetOptions = {
keepErrors?: boolean; // keep both error channels
keepTouched?: boolean; // keep the touched map
keepSubmitCount?: boolean; // keep submitCount
};FieldSnapshot<TValue>
Returned by getFieldState and passed to watchField listeners:
type FieldSnapshot<TValue> = {
value: TValue;
error: readonly string[] | undefined;
touched: boolean;
dirty: boolean;
isValidating: boolean;
};Validation results
type ValidationResult<TOutput> =
| { kind: "valid"; data: TOutput }
| { kind: "invalid"; errors: ErrorMap }
| { kind: "pending"; promise: Promise<SettledValidationResult<TOutput>> };
type FieldValidationResult =
| { kind: "valid" }
| { kind: "invalid"; errors: readonly string[] }
| { kind: "pending"; promise: Promise<SettledFieldValidationResult> };SettledValidationResult / SettledFieldValidationResult are the same unions without the "pending" arm.
React hooks
| Hook | Signature | Notes |
|---|---|---|
useForm | useForm(schema, options): Form<TSchema> | lazy-creates a form held for the component's lifetime; schema/options changes after mount are ignored (warned once) |
useField | useField(form, path, options?): UseFieldReturn<V> | one field's slice + helpers; path may be a selector (state) => string (returns UseFieldReturn<unknown>); options: { debounceMs?: number } debounces triggered validation |
useFieldArray | useFieldArray(form, path): UseFieldArrayReturn<TItem> — TItem inferred from the schema through the path (explicit <TItem> only for schema-less FieldFormApi forms) | array ops + stable ids; see Field arrays |
useFormSelector | useFormSelector(form, selector): U | selector-style subscription over FormState |
useFormSelectorShallow | useFormSelectorShallow(form, selector): U | shallow-compared variant, required for object/array-returning selectors |
useFormError | useFormError(form): readonly string[] | undefined | shortcut for the root "" error |
useIsDirty | useIsDirty(form, path?): boolean | any field dirty (derived); a typed path scopes it to that subtree ("shipping" covers shipping.city) |
useIsValid | useIsValid(form, path?): boolean | no errors currently in the merged map (not a fresh validation); a typed path scopes it to errors at or under that path |
useIsSubmitting | useIsSubmitting(form): boolean | state.isSubmitting |
useSubmitCount | useSubmitCount(form): number | state.submitCount |
createFormContext | createFormContext<TSchema>(): { Provider, useFormContext } | typed context factory for prop-drilling-free forms |
createFormHooks | createFormHooks(form, name?): FormHooks | every hook pre-wired to one form, with the name baked into the hook names ("invoice" → useInvoiceField…) — the provider-free way to share a module-singleton form; see State |
The pre-0.2 names useFormState / useFormStateShallow were renamed to useFormSelector / useFormSelectorShallow (React DOM ships its own, deprecated, useFormState and auto-imports regularly grabbed the wrong one); the deprecated aliases were removed in 0.4.0.
UseFieldReturn<TValue>
| Field | Type | Notes |
|---|---|---|
path | string | the resolved path — used as the input's name |
value | TValue | typed via FieldValue when the form carries a schema |
initialValue | TValue | the initialValues slice dirty compares against |
emptyValue | null | undefined | what a cleared input writes back: introspected from the zod schema (.nullable() → null, .optional() → undefined), with an initial-value fallback for schema-less forms |
error | readonly string[] | undefined | from the merged error map |
touched / dirty / isValidating | boolean | |
setValue(v) | writes the value and triggers mode-appropriate validation | |
setTouched(touched?) | ||
setError(errors) | writes the server channel at this path; a single string or a readonly string[], like form.setError | |
clearError() | clearErrors(path) — both channels at this path and descendants | |
validate() / validateAsync() | field-scoped validation | |
onBlur() | marks touched and triggers mode-appropriate validation |
UseFieldArrayReturn<TItem>
| Field | Type |
|---|---|
fields | readonly { id: string; value: TItem }[] — use id as the React key |
items | readonly TItem[] |
length | number |
error | readonly string[] | undefined — the array-level error |
push(item) / remove(index) / insert(index, item) / move(from, to) / swap(a, b) | wrappers over the form's array ops |
Components
All bound components render name={path}, aria-invalid, aria-describedby, and the error with role="alert", and accept a ref to the underlying element — see Bound components. Their path prop is typed against the form: passing a real Form<TSchema> narrows it to the schema's FieldPath union (typos are compile errors, template-literal array paths still typecheck), while a structural FieldFormApi keeps plain string — the PathsOf<F> type captures this rule.
| Component | Props type | Element |
|---|---|---|
TextField | TextFieldProps | input (text/password/email/url/tel) |
NumberField | NumberFieldProps | input type="text" inputMode="decimal" with partial-entry handling |
CheckboxField | CheckboxFieldProps | input type="checkbox" |
SelectField | SelectFieldProps<T> (options: SelectFieldOption<T>[], placeholder?) | select, stays controlled while empty |
Prop builders
Pure functions over a useField result, for custom markup:
| Builder | Returns | Spread onto |
|---|---|---|
textInputProps(field) | TextInputProps | <input> / <textarea> |
numberInputProps(field) | NumberInputProps | <input> (stateless type="number" binding) |
checkboxProps(field) | CheckboxProps | <input> |
selectProps(field) | SelectProps | <select> |
The rules the built-in bindings follow are exported alongside them, so adapters for other UI kits (MUI, etc.) can share them instead of re-deriving — see Bound components:
| Helper | Signature | Notes |
|---|---|---|
numberToInputText | (value: number | null | undefined) => string | canonical display text for a numeric field value ("" for empty/null/NaN) |
parseNumberText | (text: string) => ParsedNumberText | classifies user-typed text: { kind: "number", value } for a finite number, { kind: "empty" } for whitespace-only, { kind: "invalid" } for partial entries (-, 1., 1e) and Infinity |
emptyValueForSchema | (schema: z.ZodType) => null | undefined | the empty representation a field's schema accepts — null when nullable (and not optional), undefined otherwise; the schema-introspection rule behind useField().emptyValue |
focusFirstError(errors, root?)
Focuses the first control (in DOM order) whose name matches an errored path — exactly or as a descendant of an errored container path. Most specific wins: the root "" error falls back to the first control only when no field-keyed error matches — and with the default document scope that fallback is refused (returns false) when the page holds more than one <form>, since "first control" would be ambiguous. On multi-form pages pass the form element (e.g. via a ref) as root. Skips controls that can't take focus (hidden, disabled, inside a closed <dialog>) and verifies focus actually landed, trying the next match otherwise. Returns boolean — true only when a control actually holds focus; SSR-safe to import.
focusField(path, root?)
The imperative sibling of focusFirstError, keyed by a path instead of an error map: focuses the first control (in DOM order) whose name is path itself or a descendant of it — focusField("address") lands on the first rendered address field. The root "" path is whole-form scope (consistent with validateField("") / resetField("")): it focuses the first focusable control in scope — and, like focusFirstError's root-"" fallback, refuses to guess (returns false) under the default document scope when the page holds more than one <form>; pass the form element as root. Same focusability rules and the same optional root scoping; returns whether a control actually received focus. Use it where react-hook-form users reach for setFocus: after opening a dialog, appending an array row, or landing on a step — see the recipe.
Core utilities
Exported for building on top of the same primitives the library uses:
parsePath(path: string): readonly PathSegment[] // "a.0.b" → ["a", 0, "b"]
getAtPath(obj: unknown, path: string): unknown // read a dot path
setAtPath<T>(obj: T, path: string, value): T // immutable write, containers created as needed
validateSync(schema, values): SettledValidationResult<Output> // safeParse + flattenIssues
validateAsync(schema, values): Promise<SettledValidationResult<Output>>
flattenIssues(issues): ErrorMap // zod issues → path-keyed map (unions expanded per branch)
isAsyncRequiredError(e: unknown): boolean // "schema needs async parsing" detector
shouldValidateOn(trigger, mode, reValidateMode, submitAttempted, touched?): booleanshouldValidateOn is the mode-resolution rule the hooks use: trigger is "change" or "blur", and reValidateMode replaces mode once submitAttempted is true.
Exported types
Everything importable via import type { ... } from "formstand":
- Core:
Form,CreateFormOptions,SubmitHandler,InvalidSubmitHandler,SubmitOptions,SubmitResult,ResetOptions,ReadonlyStoreApi,FieldSnapshot - State:
FormState,ErrorMap,BoolMap - Paths:
PathSegment,FieldPath,FieldValue - Validation:
ValidationResult,SettledValidationResult,FieldValidationResult,SettledFieldValidationResult,ValidationMode,ValidationTrigger - Hooks:
FormStateApi,UseFieldReturn,FieldFormApi,FieldPathArg,UseFieldOptions,UseFieldArrayReturn,FieldArrayFormApi,FieldArrayEntry,FormProviderProps,FormContextApi - Components:
TextFieldProps,NumberFieldProps,CheckboxFieldProps,SelectFieldProps,SelectFieldOption,FieldRef,PathsOf - Prop builders:
TextInputProps,NumberInputProps,CheckboxProps,SelectProps,ParsedNumberText
FieldFormApi / FieldArrayFormApi / FormStateApi are the structural (schema-less) form interfaces the hooks accept — useful for writing reusable field components that take any form. When a real Form<TSchema> is passed, the typed overloads bind instead and path inference is preserved.
Implementing these interfaces
Their methods are deliberately declared with method-shorthand syntax (setValue(path: string, value: unknown): void), so TypeScript checks the parameters bivariantly. That is what lets a Form<TSchema> — whose write methods take the narrower FieldPath<...> instead of string — satisfy the string-typed interface. Two consequences if you implement one of these shapes yourself:
- Your implementation must accept any string path at runtime — the compiler will not stop a caller from passing a path outside whatever narrower type you had in mind.
- Do not re-declare these shapes with arrow-property syntax (
setValue: (path: string, ...) => void): property function types are checked contravariantly, which makesForm<TSchema>unassignable to your copy.