FormState Guide
FormState is a type-safe React form management library that pairs with Zod Mini for schema validation. It gives you full control over field state — values, errors, dirty flags, touched tracking, ranges, and more — without forcing you to adopt a specific rendering pattern.
Key traits:
- Schema-first — one Zod schema drives types, validation, constraints, and metadata.
- Path expressions — address nested and array fields with typed lambda paths:
(path) => path.info.address. - End-to-end type safety — generics flow from schema to every method signature, callback, and state accessor.
- No magic re-renders — you call
change, you readformState.data, React re-renders normally. - React 19 ready — works with server actions,
useFormStatus, and the newactionprop on<form>. - Zero-config defaults — validation on change, errors after touch, submit flow all work out of the box.
Installation
FormState requires React 19.2 or greater, Zod 4.x and TypeScript 5.9 or greater.
Install FormState — Zod v4 is bundled as a peer dependency. NPM 7+ installs it automatically, PNPM does not.
npm install react react-dom zod
npm install github:dstarosta/FormState#v1.0.0Then import from the package root:
import { useFormState, z } from 'form-state';z should be imported from 'form-state', not 'zod' or 'zod/mini'.
ESLint
FormState ships an official ESLint plugin that catches common mistakes at development time — unstable references, incorrect schema primitives, and unsafe password inputs. recommended is the only available config and enables all rules.
The plugin requires ESLint 9.0 or greater, which introduced flat config as the default format.
Add formStatePlugin.configs.recommended to the extends array of your flat config:
// eslint.config.js
import { defineConfig } from 'eslint/config';
import formStatePlugin from 'form-state/eslint';
export default defineConfig([
{
extends: [
// ... your other configs
formStatePlugin.configs.recommended,
],
rules: {},
},
]);Rule severities can be overridden individually via rules:
rules: {
'form-state/avoid-input-password': 'error',
}Rules
| Rule | Description | Default | Fixable |
|---|---|---|---|
avoid-input-password | Enforces using <SecureInput> instead of <input type="password"> inside forms with an action or onSubmit handler. Plain password inputs expose the value in the DOM, in DevTools, and to browser extension scanners. | warn | ✅ |
no-watch-dependency | Prevents using useWatch() return values as hook dependencies. useWatch values change via external store subscriptions and are not synchronized to React state — they must not appear in dependency arrays. | error | |
stable-debounced-callback | Enforces stable callback references in change() calls that set debounceIntervalMs. An inline function resets the debounce timer on every render, preventing the callback from ever firing. | error | |
stable-listener | Enforces stable listener references passed to useListener(). Inline functions create a new reference on every render, causing the subscription to unsubscribe and resubscribe continuously. | error | |
use-form-schema | Enforces using FormState's formString(), formNumber(), formBoolean(), formDate(), and formArray() helpers instead of raw Zod primitives in form schemas. These helpers add the blank-value and optionality semantics HTML inputs require. | warn | ✅ |
Philosophy
FormState is built around a small set of ideas that inform every design decision. Understanding them makes the library predictable to use and straightforward to extend.
TypeScript First
Types flow end-to-end from your schema through every method signature, callback, and component prop. There are no any casts, no silent type widening, and no generics to pass by hand — inference does the work.
Path expressions are type-checked at compile time. (path) => path.info.age is valid or it isn't, and the error lands on your input's prop — not inside a render-prop wrapper two levels up. After a successful submit, state.data is fully narrowed: string, not string | ''.
See the TypeScript section for the full exported type reference and tsconfig requirements.
Built for React 19
FormState is built around React 19's form model, not bolted on top of it. The Form component returned by the hook accepts an async action prop directly — no onSubmit wiring, no e.preventDefault(). Because it uses a real <form action>, React 19's useFormStatus works in any child component without any extra setup, and server actions drop in without adapters. This deep integration means:
- The browser's native form submission behavior is fully preserved. See Form Submission, Next.js and React Router 7.
- Server Actions drop in seamlessly with no adapters or wrappers required. See Next.js and React Router 7.
FormDatais natively supported along withJSONdata. See Form Submission.Suspensesupport allows you to declaratively handle loading states for pending submissions. See TanStack Query.ErrorBoundaryworks out of the box to gracefully catch and display submission errors. See TanStack Query.useFormStatusworks in any child component without extra setup or context providers.useOptimisticintegrates naturally for instant UI feedback during submissions.startTransitionis fully supported. It allows to render form components in the background.1- Predictable render cycle, even for uncontrolled components.
useWatchhook is implemented withuseSyncExternalStore.2 - Immutable form state supports built-in memoized selectors and standard React hooks like
useEffect/useLayoutEffect,useMemoanduseCallback.
Libraries that are using
useSyncExternalStorefor form state management have problems with React concurrent rendering, specifically background transitions.React Form Hook: React v19 Discussion #11832
Tanstack Store (powers Tanstack Form): useSyncExternalStore and React concurrent mode #262
- Variables returned from the
useWatchhook should only be used in the render function, not as other hook dependencies.
Schema-Driven, Non-Intrusive
One Zod schema is the single source of truth for field types, validation rules, error messages, allowed values, ranges, and human-readable labels. It lives outside your components — controls are plain HTML inputs that never import or reference the schema directly.
This keeps component signatures simple and the schema portable. You can reuse the same schema for server-side API validation, share it across multiple forms, or version it independently of your UI. The schema is expressive — cross-field rules, async validators, conditional requirements — but that complexity stays in one place rather than being distributed across input components.
Controlled is Cool
Every field is controlled by default: formState.data is the source of truth and React renders from it. This makes the form fully predictable — any state transition is visible as a plain object diff, which means conditional logic, cross-field validation, and debugging are all just JavaScript.
Uncontrolled inputs are available as an escape hatch for high-frequency fields where re-rendering on every keystroke is undesirable. The value is read from the DOM at submit time and fires no intermediate state updates:
<input
name="bio"
defaultValue={formState.data.bio}
onBlur={(e) => formActions.change('bio', e.target.value, { touch: true })}
/>To observe a raw typed value — a live character counter, a search preview — without triggering a full re-render, see useWatch.
Immutable form state
Every formState update produces a new object reference — no in-place mutation, no shared mutable containers. This is what makes FormState safe for the full range of React patterns: useMemo, useEffect dependency arrays, React.memo, and the React Compiler all rely on reference equality to detect changes. Libraries that store form state in a mutable ref or an external store — including React Hook Form and TanStack Form — bypass React's change-detection entirely, which means those optimisations silently stop working for form-derived values.
The getState method is used to read form data in components or utility modules that only have access to the disconnected copy of the form data and the underlying Zod schema.
import { getState } from 'form-state';
const address1Zip = getState(formSchema, data, (path) => path.addresses[0].zipCode);
The createState method is used to generate an object with an initial state outside of the form context.
import { createState } from 'form-state';
const data = createState(schema);
// Override some default property values with a partial initial state object.
const data = createState(schema, { name: 'John', info: { age: 24 } });The updateState method is used to update partial state graphs and assigning them back using the change method. The syntax is very similar to the produce method in the Immer library.
import { updateState } from 'form-state';
const updatedTags = updateState(data.tags, (draft) => {
draft.push('Important'); // can be a complex array/object structure
});
formActions.change('tags', updatedTags);Also see Array Fields
No Render Props
TanStack Form, React Hook Form, and Formik all manage per-field subscriptions by wrapping each input in a special component or render prop. FormState takes a different approach: state lives in a plain object you read directly, and you wire inputs yourself with ordinary props. You can always build a layer of abstracted components on top — but you never have to.
// Every field needs a render-prop wrapper
<form.Field name="name">
{(field) => (
<div>
<input
value={field.state.value}
onChange={(e) =>
field.handleChange(e.target.value)
}
onBlur={field.handleBlur}
/>
{field.state.meta.errors.map((err) => (
<span key={err}>{err}</span>
))}
</div>
)}
</form.Field>// Controlled inputs need a render-prop wrapper
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<div>
<input {...field} />
{fieldState.error && (
<span>
{fieldState.error.message}
</span>
)}
</div>
)}
/>// Field render prop to access per-field state
<Field name="name">
{({ field, meta }) => (
<div>
<input {...field} />
{meta.touched && meta.error && (
<span>{meta.error}</span>
)}
</div>
)}
</Field>// No wrapper — read state and call actions directly
<div>
<input
value={formState.data.name}
onChange={(e) => formActions.change('name', e.target.value)}
onBlur={() => formActions.touch('name')}
/>
{formState.touched.name && formState.errors.name && (
<span>{formState.errors.name}</span>
)}
</div>There is no field object, no fieldState, no render prop argument. You work with the same formState and formActions everywhere. Passing data to a child component is a normal prop. Conditional fields are plain JSX. Moving a field into its own component means passing formState and formActions as props — no re-registration, no useController.
Render-prop wrappers exist to limit re-renders to the field level. FormState re-renders the component that called useFormState on every state change. For most forms this is imperceptible, but if you have hundreds of fields you can reduce churn by using uncontrolled inputs with defaultValue, useWatch to subscribe to individual fields, or debouncing change calls on high-frequency inputs.
TypeScript
FormState is built around a handful of exported generic types that flow from your schema through every callback, accessor, and component prop. Understanding them unlocks the full type-safety story.
Requirements
FormState's types rely on features and inference improvements introduced in recent TypeScript releases. Two requirements apply to all projects using this library:
Types require TypeScript 5.9 or greater. Earlier versions will produce incorrect or missing type errors.
Enable "strict": true in your tsconfig.json. Without it several key checks — including narrowing inside handleSubmit and discriminated-union narrowing on SubmitState — will not fire.
// tsconfig.json
{
"compilerOptions": {
"strict": true
}
}Type depth limitation
Path inference supports up to 10 levels of nesting. Beyond that the type resolves to an empty tuple and path inference stops working.
Keep schemas at 10 levels of nesting or fewer. If you find yourself hitting the limit, flatten repeated sub-structures into their own schemas and compose them at the top level rather than nesting further.
FormPath<T>
The type of a path expression — either a top-level key string or an arrow function that navigates the form data tree. Use it when passing a field reference as a prop.
import { type FormPath } from 'form-state';
// A component that displays an error for any field
function FormError<T>({
path,
errors,
}: {
path: FormPath<T>;
errors: FormState<T>['errors'];
}) {
const message = errors.get(path as (p: T) => unknown);
return message ? <p className="error">{message}</p> : null;
}
// Caller — the compiler checks the path against FormSchema
<FormError path={(path: FormSchema) => path.info.age} errors={formState.errors} />FormState<T>
The type of formState itself. Use it when writing callbacks that receive the state snapshot — e.g. debounce callbacks or change listeners.
The callback option requires a stable reference — define it outside the component or wrap it in useCallback.
import { type FormState } from 'form-state';
// Debounce callback — typed state snapshot
function NoteTextInput({
index,
callback,
}: {
index: number;
callback?: (state: FormState<FormSchema>) => void;
}) {
const { formActions } = useSchemaFormState();
return (
<input
onChange={(e) =>
formActions.change((path) => path.notes[index].text, e.target.value, {
debounceIntervalMs: 500,
callback, // fires after debounce with latest FormState
touch: true,
})
}
/>
);
}FormMode
The union 'editable' | 'readOnly' | 'disabled'. Use it when building mode selector components.
import { type FormMode } from 'form-state';
type ModeSelectorProps = {
mode: FormMode;
onChange: (mode: FormMode) => void;
};
// The compiler rejects anything that is not a valid mode string
formActions.setMode('readOnly'); // ✅
formActions.setMode('published'); // ❌ Type errorSubmitState<T>
The discriminated union passed to your handleSubmit callback. Narrow on valid to get a typed data object.
import { type SubmitState } from 'form-state';
const onSubmit = async (state: SubmitState<FormSchema>, formData: FormData) => {
if (!state.valid) {
// state.errors is Record<string, string>
// state.data is DeepPartial<FormSchema> — may have empty/invalid fields
return state.errors;
}
// state.data is fully typed FormSchema here — no cast needed
await api.save(state.data.name); // string, not string | ''
await api.save(state.data.age); // number, not number | ''
return true;
};SubmitSuccessState<T>
Passed to the onSuccess callback — guaranteed valid data plus the raw FormData.
import { type SubmitSuccessState } from 'form-state';
function App() {
const handleFormSubmit = ({ data, formData }: SubmitSuccessState<FormSchema>) => {
// data is fully typed FormSchema
const output = formData.get('output');
if (output === 'redirect') {
router.push('/done');
} else {
showDialog({ data, formData });
}
};
return <AppForm onSubmitted={handleFormSubmit} />;
}SchemaDataObject<T>
Produced by schema.toObject(data) or parseState(schema, obj, true).data — internal fields stripped, optional empties removed.
import { type SchemaDataObject } from 'form-state';
// Component that displays clean API-ready data
function JsonData({ data }: { data: SchemaDataObject<FormSchema> }) {
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
// Usage
<JsonData data={schema.toObject(formState.data)} />StateChangeListener<T>
The type for formHooks.useListener callbacks.
import { type StateChangeListener } from 'form-state';
// Define once outside the component to keep the reference stable
const listener: StateChangeListener<FormSchema> = ({ type, data, submitCount }) => {
if (type === 'submit') {
analytics.track('form_submit', { count: submitCount, name: data.name });
}
};
// In your component:
formHooks.useListener(listener);DeepPartial<T>
A recursive Partial — every property at every level of nesting becomes optional. The initialData option accepts DeepPartial<T>, so you can seed the form from an API response or a saved draft without providing every field.
import { type DeepPartial } from 'form-state';
const { data: draft } = useQuery<DeepPartial<FormSchema>>({
queryKey: ['draft', draftId],
queryFn: () => api.getDraft(draftId),
});
// Pass it directly as initialData — no need to fill in every field
const { formState, formActions } = useFormState(schema, {
initialData: draft,
});License
FormState is released under the MIT License.
MIT License
Copyright (c) 2026 Dmitry Starosta
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.Most libraries referenced in this guide are also MIT-licensed: React Hook Form, TanStack Form, TanStack Query, Next.js, React Router, Zod, and fast-equals. Formik is licensed under Apache 2.0.
Defining a Schema
Every form starts with a schema. FormState re-exports a z object that extends Zod Mini with form-aware field constructors. These constructors encode required, error messages, date formats, and allowed values directly into the schema so FormState can read them automatically.
import { z } from 'form-state';
const schema = z.strictObject({
// Required string with custom error and extra validators
name: z
.formString(
{ required: true, error: 'Name is required' },
z.regex(/^[\w\s'-]+$/, 'Name contains invalid characters'),
z.maxLength(50, 'Name must be 50 characters or less')
)
.with(z.describe('Full Name')),
// Optional number with range constraints
age: z
.formNumber(z.gte(0, 'Age must be positive'), z.lte(150, 'Age is out of range'))
.with(z.describe('Age')),
// Date with a specific display format
birthDate: z
.formDate(
{ dateFormat: 'MM/dd/yyyy' },
z.gte(new Date(1900, 0, 1), 'Date too far in the past')
)
.with(z.describe('Birth Date')),
// Enum — only these string literals are valid
status: z.formValues(['active', 'inactive', 'pending']).with(z.describe('Status')),
// Boolean without an empty string value
isAdmin: z.formBoolean({ required: true }).with(z.describe('Admin')),
// Array of strings with length limits
tags: z.formArray(z.string(), { minLength: 0, maxLength: 10 }).with(z.describe('Tags')),
});
type Schema = z.infer<typeof schema>;
// {
// name: string;
// age: number | '';
// birthDate: Date | '';
// status: 'active' | 'inactive' | 'pending' | '';
// isAdmin: boolean;
// tags: string[];
// }Cross-field validation
Add .check(z.validate(...)) to the top-level object to express constraints that span multiple fields.
const schema = z
.strictObject({
priority: z.formBoolean(),
notes: z.formArray(
z.object({ typeId: z.formNumber(), text: z.formString() })
),
})
.check(
z.validate(
(data) => data.priority || data.notes.every((note) => note.typeId !== 2),
{ path: 'priority', error: 'Priority must be set to allow priority notes.' }
)
);Form-aware field constructors
| Constructor | Type | Notes |
|---|---|---|
z.formString(opts?, ...checks) | string | '' | Empty string when blank. required flags empty as invalid. |
z.formNumber(opts?, ...checks) | number | '' | Empty string until a number is entered. |
z.formBoolean(opts?) | boolean | Coerces truthy/falsy strings from form data. |
z.formDate(opts?, ...checks) | Date | '' | dateFormat drives how string values are parsed. |
z.formValues(values[]) | 'a' | 'b' | '' | Literal union plus an empty string for "not selected". |
z.formArray(item, opts?) | T[] | minLength/maxLength validate array length. |
z.symbol() | symbol | Auto-generates a UUID symbol — useful for array field map keys that are not a part of the API schema. It allows React to track components that have been created on the client and do not have a real ID yet. |
z.object(shape) | { ... } | Nested object shape — strips unknown keys silently. Use for any sub-object within the schema: direct nested fields or per-element shapes inside z.formArray(). |
z.strictObject(shape) | { ... } | Root schema container — rejects validation if any key is not declared in the schema. Prefer this at the top level so unexpected fields are a hard error rather than silent data loss. |
Using z.strictObject means that if the API starts returning a new field that is not yet declared in the schema, validation will fail. Keep the schema in sync with the API, or switch to z.object if your integration cannot tolerate strict key checking.
Chain .with(z.describe('Label')) on any field, object or array to attach a human-readable description that you can read back via formState.descriptions. This is useful for control labels.
Schema Methods
These are the methods z exports from form-state that have no direct Zod equivalent. Each one wraps a standard Zod schema with form-specific preprocessing, metadata, or validation behaviour. Call them at schema-definition time, not inside components.
z.formString(opts?, ...checks)
Returns a schema that accepts string | ''. An empty string represents a blank field. When required is true, an empty string is treated as invalid.
| Option | Type | Default | Description |
|---|---|---|---|
required | boolean | false | Makes an empty string invalid. |
error | string | — | Custom error message for required and type validation. |
allowEmpty | boolean | true | Whether toObject() retains an empty string in the output. Set to false to strip blank fields from submitted data. |
Zod string checks — z.minLength, z.maxLength, z.regex, etc. — are passed as additional arguments after the options object.
// Optional string
z.formString()
// Required with custom error
z.formString({ required: true, error: 'Name is required' })
// Checks only — no options object needed
z.formString(z.maxLength(100, 'Too long'), z.regex(/^\w+$/, 'Letters only'))
// Required with options and checks
z.formString({ required: true, error: 'Required' }, z.maxLength(50))
// Strip empty strings from toObject() output
z.formString({ allowEmpty: false })
// Use built-in regex patterns from z.regexes instead of writing your own
z.formString({ required: true, error: 'Email is required' }, z.regex(z.regexes.email, 'Invalid email address'))
z.formString(z.regex(z.regexes.e164, 'Must be in international format, e.g. +12125551234'))z.formNumber(opts?, ...checks)
Returns a schema that accepts number | ''. An empty string represents a blank numeric input. Non-numeric values produce a validation error.
| Option | Type | Default | Description |
|---|---|---|---|
required | boolean | false | Makes an empty string invalid. |
error | string | — | Custom error message for required and type validation. |
// Optional number
z.formNumber()
// Required with custom error
z.formNumber({ required: true, error: 'Age is required' })
// Checks only — no options object needed
z.formNumber(z.gte(0, 'Must be positive'), z.lte(150))
// Required with options and checks
z.formNumber({ required: true, error: 'Required' }, z.gte(18, 'Must be 18 or older'))z.formBoolean(opts?)
Returns a schema that accepts boolean | ''. An empty string represents an unchecked state. Truthy/falsy strings from raw form data are coerced to boolean.
| Option | Type | Default | Description |
|---|---|---|---|
required | boolean | false | Makes a missing or empty value invalid. |
error | string | — | Custom error message for required validation. |
// Optional — value is boolean or ''
z.formBoolean()
// Required — must be a boolean
z.formBoolean({ required: true })z.formDate(opts?, ...checks)
Returns a schema that accepts Date | ''. String input is parsed according to dateFormat; invalid strings produce a validation error rather than crashing. An empty string represents a blank date input.
| Option | Type | Default | Description |
|---|---|---|---|
required | boolean | false | Makes an empty string invalid. |
error | string | — | Custom error message for required validation. |
dateFormat | string | 'yyyy-MM-dd' | Format string used to parse string inputs into Date objects. Also stored in schema metadata and readable via formState. |
dateFormatError | string | — | Error message shown when a string cannot be parsed as a valid date. |
// Optional date — defaults to 'yyyy-MM-dd' parsing
z.formDate()
// Custom display format
z.formDate({ dateFormat: 'MM/dd/yyyy' })
// Required with custom format and range check
z.formDate(
{ required: true, dateFormat: 'MM/dd/yyyy', error: 'Date is required' },
z.gte(new Date(1900, 0, 1), 'Date too far in the past')
)
// Checks only — no options object needed
z.formDate(z.gte(new Date(2000, 0, 1), 'Must be after year 2000'))z.formValues(values, opts?)
Returns an enum-like schema that accepts one of the provided string literals or an empty string (representing "not selected"). When required is true, only the listed values are accepted and the inferred type narrows accordingly.
| Option | Type | Default | Description |
|---|---|---|---|
required | boolean | false | Makes an empty selection invalid. Narrows the output type to the listed values only. |
error | string | — | Error message shown when the value is not one of the allowed values. |
// Optional — 'active' | 'inactive' | 'pending' | ''
z.formValues(['active', 'inactive', 'pending'])
// Required — '' is invalid; type is 'active' | 'inactive' | 'pending'
z.formValues(['active', 'inactive', 'pending'], { required: true, error: 'Select a status' })Values must be non-empty strings. Passing an empty array or including an empty string in values throws a TypeError at schema-construction time.
z.formArray(elementSchema, opts?)
Returns a Zod array schema wrapping elementSchema. The element schema must not itself be an array or object — use z.array() or z.object() directly for those shapes. By default the array is required; pass { required: false } to make it optional (undefined).
| Option | Type | Default | Description |
|---|---|---|---|
required | boolean | true | When false, wraps the array in z.optional() so the field may be absent. |
minLength | number | — | Minimum number of items. Exposed via formState.ranges. |
maxLength | number | — | Maximum number of items. Exposed via formState.ranges. |
error | string | — | Error message for array type validation. |
lengthError | string | — | Error message for minLength / maxLength violations. |
// Required array of strings
z.formArray(z.string())
// With length constraints
z.formArray(z.formString(), { minLength: 1, maxLength: 10, lengthError: 'Must have 1–10 tags' })
// Optional — may be undefined
z.formArray(z.formNumber(), { required: false })z.validate(predicate, params?)
Creates a Zod check for whole-object (cross-field) or single-field validation. Unlike z.refine, it runs on every validation pass by default — the optional condition parameter lets you gate it on the current set of prior errors. Pass the returned check to .check() on a z.object() or z.strictObject().
It can also be used for arrays or fields to perform custom validation that cannot be performed with the standard Zod-based method.
| Parameter | Type | Default | Description |
|---|---|---|---|
predicate | (item: T) => boolean | — | Returns true when the value is valid. |
condition | (errors: ZodValidationError[]) => boolean | always runs | Called with the current validation errors before running the predicate. Return false to skip this check entirely. |
path | PropertyKey | PropertyKey[] | — | Key in formState.errors where the error is stored. Defaults to the root (no path). |
error | string | — | Custom error message. |
// Shorthand — second argument is the error string
z.validate(
(data) => data.endDate > data.startDate,
'End date must be after start date'
)
// With path — error is stored under 'endDate'
z.validate(
(data) => !data.endDate || data.endDate > data.startDate,
{ path: 'endDate', error: 'End date must be after start date' }
)
// With condition — skips this check when 'startDate' already has an error
z.validate(
(data) => data.endDate > data.startDate,
{
condition: (errors) => !errors.some((e) => e.pathNotation === 'startDate'),
path: 'endDate',
error: 'End date must be after start date',
}
)
// Attach to an object schema
const schema = z
.strictObject({ startDate: z.formDate(), endDate: z.formDate() })
.check(
z.validate(
(data) => !data.endDate || !data.startDate || data.endDate > data.startDate,
{ path: 'endDate', error: 'End date must be after start date' }
)
);z.someItem(predicate, error?)
Creates a Zod check that passes when at least one array element satisfies the predicate — equivalent to Array.prototype.some. Use with .check() on an array schema.
const schema = z.strictObject({
participants: z.formArray(
z.object({ role: z.formValues(['owner', 'member']), name: z.formString() })
).check(
z.someItem(
(item) => item.role === 'owner',
'At least one participant must be an owner'
)
),
});z.everyItem(predicate, error?)
Creates a Zod check that passes when all array elements satisfy the predicate — equivalent to Array.prototype.every. Use with .check() on an array schema.
const schema = z.strictObject({
prices: z.formArray(z.formNumber()).check(
z.everyItem((price) => price === '' || price > 0, 'All prices must be positive')
),
});z.uniqueItems(deepEquality?, params?)
Creates a Zod check that ensures all array items are unique. The error is placed at the path of the first duplicate element, making it easy to highlight the offending item in the UI. Use with .check() on an array schema.
| Parameter | Type | Default | Description |
|---|---|---|---|
deepEquality | boolean | false | Use deep equality instead of reference equality (===). |
mapFn | (item: T, index: number) => unknown | — | Map each item to a comparison value before checking uniqueness. Use to compare a specific property rather than the whole object. |
error | string | — | Custom error message placed on the duplicate element. |
elementPath | PropertyKey[] | — | Path within each array element where the error is attached. E.g. ['email'] makes the error appear under items[1].email. |
ignoreValues | unknown[] | — | Values skipped when checking for duplicates — typically [''] to ignore blank entries. |
// Unique primitive strings — ignore blanks
z.formArray(z.formString()).check(
z.uniqueItems(false, { error: 'Duplicate tag', ignoreValues: [''] })
)
// Unique by a property — error appears at the duplicate item's 'email' field
z.formArray(z.object({ email: z.formString(), name: z.formString() })).check(
z.uniqueItems(false, {
mapFn: (item) => item.email,
elementPath: ['email'],
error: 'Email must be unique',
ignoreValues: [''],
})
)
// Deep equality for object items
z.formArray(z.object({ x: z.formNumber(), y: z.formNumber() })).check(
z.uniqueItems(true, { error: 'Duplicate coordinate' })
)Missing Zod functionality
Any Zod feature not directly re-exported by form-state is available under z.advanced. This includes types such as z.advanced.union, z.advanced.nullable, z.advanced.transform, and others.
Methods under z.advanced are not guaranteed to be compatible with FormState. They bypass the form-aware preprocessing layer, so features like required validation, empty-string handling, and field metadata may not work as expected.
Basic Usage
Call useFormState(schema, options) in your component and destructure what you need. The Form component returned by the hook is a pre-wired <form> element — use it in place of a plain <form>.
import { useFormState, z, convert } from 'form-state';
const STATUS_VALUES = ['active', 'inactive', 'pending'] as const;
const schema = z.strictObject({
name: z.formString({ required: true, error: 'Name is required' }).with(z.describe('Name')),
email: z.formString().with(z.describe('Email')),
status: z.formValues(STATUS_VALUES).with(z.describe('Status')),
isAdmin: z.formBoolean({ required: true }).with(z.describe('Admin')),
}).with(z.describe('Profile'));
export function ProfileForm() {
const {
formState,
formStatus,
formActions,
formHandlers,
formHooks,
Form,
} = useFormState(schema, {
initialData: { name: 'Alice' },
});
const handleSubmit = formHandlers.handleSubmit(async (submitState) => {
if (!submitState.valid) return submitState.errors;
await saveProfile(submitState.data);
return true;
});
return (
<Form action={handleSubmit}>
<h2>{formState.descriptions['']}</h2> {/* 'Profile' */}
<label>
{formState.descriptions.name}
<input
type="text"
value={formState.data.name}
onChange={(e) => formActions.change('name', e.target.value)}
onBlur={() => formActions.touch('name')}
/>
{formState.errors.name && <span>{formState.errors.name}</span>}
</label>
<label>
{formState.descriptions.email}
<input
type="email"
value={formState.data.email}
onChange={(e) => formActions.change('email', e.target.value)}
/>
</label>
<label>
{formState.descriptions.status}
<select
value={formState.data.status}
onChange={(e) =>
formActions.change('status', convert.toLiteral(e.target.value, STATUS_VALUES))
}
onBlur={() => formActions.touch('status')}
>
<option value="">Select status…</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="pending">Pending</option>
</select>
{formState.errors.status && <span>{formState.errors.status}</span>}
</label>
{/* Radio buttons — alternative rendering of the same field */}
<fieldset onBlur={() => formActions.touch('status')}>
<legend>{formState.descriptions.status}</legend>
{STATUS_VALUES.map((status) => (
<label key={status}>
<input
type="radio"
value={status}
checked={formState.data.status === status}
onChange={(e) =>
formActions.change('status', convert.toLiteral(e.target.value, STATUS_VALUES))
}
/>
{status}
</label>
))}
{formState.errors.status && <span>{formState.errors.status}</span>}
</fieldset>
<label>
<input
type="checkbox"
checked={formState.data.isAdmin}
onChange={(e) => formActions.change('isAdmin', e.target.checked)}
/>
{formState.descriptions.isAdmin}
</label>
<button type="submit" disabled={formStatus.submitting}>
{formStatus.submitting ? 'Saving…' : 'Save'}
</button>
</Form>
);
}Path Expressions
Every method that accepts a field reference supports two equivalent syntaxes: a string key for top-level fields, and an arrow function path for anything nested or inside an array. The arrow function form is type-checked end-to-end — the TypeScript compiler rejects paths that don't exist in the schema.
// Works for flat fields
formActions.change('name', value);
formActions.touch('name');
formState.errors.name;
formState.touched.name;
formState.dirty.name;
formState.required.name;
formState.descriptions.name;
// Arrays at the top level
formActions.array.append('tags', ['react']);
formActions.array.remove('tags', 0);// Nested objects
formActions.change((path) => path.auto.modelId, value);
formActions.touch((path) => path.info.address.city);
formState.errors.get((path) => path.info.age);
formState.touched.get((path) => path.info.age);
formState.dirty.get((path) => path.info.age);
// Array items and their fields
formActions.change((path) => path.notes[index].text, value);
formActions.array.append((path) => path.profile.skills, ['TS']);
formState.descriptions.get((path) => path.notes[0].typeId);Why arrow functions catch errors at compile time
The path lambda receives a typed proxy of your schema's data shape. The TypeScript compiler resolves the return type of the lambda and infers the correct value type for change, the correct error shape for errors.get, etc.
const schema = z.strictObject({
user: z.object({
age: z.formNumber(),
name: z.formString(),
}),
});
const { formActions, formState } = useFormState(schema);
// ✅ Correct — TypeScript knows 'age' accepts number | ''
formActions.change((path) => path.user.age, 30);
// ✅ Correct — TypeScript knows 'name' accepts string
formActions.change((path) => path.user.name, 'Alice');
// ❌ Type error — 'user.age' is number | '', not string
formActions.change((path) => path.user.age, 'hello');
// ❌ Type error — 'user.typo' does not exist on the schema
formActions.change((path) => path.user.typo, 'value');Choosing between the two forms
| Situation | Use |
|---|---|
| Top-level field in a simple form | Either — string is slightly shorter |
Nested object (path.info.age) | Arrow function |
Array item field (path.notes[index].text) | Arrow function |
Passing path as a prop (FormPath<T>) | Arrow function |
| Building the path dynamically at runtime | String (arrow function can't be dynamic) |
Form State
formState is the read-only snapshot of your form. Every property is keyed by field name and supports an optional path expression for nested access.
data
Current field values, typed from your schema.
formState.data.name // string
formState.data.age // number | ''
formState.data.info.address.city // string (nested)
formState.data.tags // string[]
formState.data.tags[0] // stringerrors
Validation error messages, one per failing field.
// Top-level field — direct property access
formState.errors.name // 'Name is required' | undefined
// Nested field — use .get() with a path expression
formState.errors.get((path) => path.info.age) // string | undefined
formState.errors.get((path) => path.notes[index].text) // string | undefined
// Manual errors set via setError()
formState.errors.getManual('_serverError')
// All current errors
formState.errors.getAll() // string[] — all messages
formState.errors.getKeys() // string[] — e.g. ['name', 'info.age']When a field fails multiple checks, messages are joined with the errorMessageSeparator option (default |).
dirty / touched / required
Each is a boolean map with the same accessor pattern:
// Direct access for top-level fields
formState.dirty.name // true after first change
formState.touched.name // true after blur / explicit touch call
formState.required.name // derived from schema — never changes
// .get() for nested fields
formState.dirty.get((path) => path.info.address.city)
formState.touched.get((path) => path.notes[index].text)
formState.required.get((path) => path.info.age)
// Enumerate all keys that are currently truthy
formState.dirty.getKeys() // ['name', 'info.age', ...]
formState.touched.getKeys()ranges
Min / max constraints extracted from the schema — ready to pass straight into HTML input attributes.
// Get the full range object
const ageRange = formState.ranges.get((path) => path.age);
// { type: 'range', format: 'integer', min: 0, max: 150 }
// Direct min/max helpers accept both forms
formState.ranges.getMin('age') // 0
formState.ranges.getMax((path) => path.age) // 150
formState.ranges.getMax('name') // 50 (string maxLength)
// Wire directly to input attributes
<input
type="number"
min={formState.ranges.getMin((path) => path.age)}
max={formState.ranges.getMax((path) => path.age)}
/>
<input
type="text"
maxLength={formState.ranges.getMax('name')}
/>descriptions & patterns
// z.describe() values are available at runtime
formState.descriptions.name // 'Full Name'
formState.descriptions.get((path) => path.info.age) // 'Age'
formState.descriptions.get((path) => path.notes[0]) // 'Note' (index normalised to [0])
// Patterns (from z.regex()) as raw strings for input[pattern]
formState.patterns.name // '^[\\w\\s\'-]+$'
formState.patterns.get((path) => path.info.id)
// Use in JSX
<label>{formState.descriptions.get((path) => path.auto.modelId)}</label>Preparing data for an API
schema.toObject(data) strips internal-only fields (like z.symbol()) and removes optional fields whose value is an empty string, producing a clean object to send to your backend.
const payload = schema.toObject(formState.data);
// uuid symbols stripped, optional empty strings omitted
await api.post('/users', payload);Form Status
High-level booleans about the form as a whole.
| Property | Type | Description |
|---|---|---|
mode | 'editable' | 'readOnly' | 'disabled' | Current form mode. |
readOnly | boolean | Shorthand for mode === 'readOnly'. |
disabled | boolean | Shorthand for mode === 'disabled'. |
dirty | boolean | True if any field value differs from its initial value. |
touched | boolean | True if any field has been touched. |
valid | boolean | null | null before first validation, then true/false. |
validSchema | boolean | null | Schema validity ignoring manual errors. |
submitting | boolean | True while handleSubmit callback is running. |
submitted | boolean | True after a successful submit. |
<button type="submit" disabled={!formStatus.dirty || formStatus.submitting}>
Save changes
</button>
{formStatus.valid === false && (
<p>Please fix the errors above before saving.</p>
)}formStatus.valid is null when validation has never run. This lets you distinguish "not yet validated" from "known valid" without extra state.
Schema Metadata
FormState extracts metadata from your schema at initialization time and exposes it as plain, typed objects — no extra wiring needed. All five live on formState and are keyed the same way as data and errors:
| Object | Source in schema | Primary use |
|---|---|---|
descriptions | .with(z.describe('…')) | Drive <label> text from a single source of truth |
ranges | z.gte / z.lte / z.minLength / z.maxLength | Wire min, max, minLength, maxLength on inputs |
required | { required: true } option on form fields | Drive the required attribute and optional asterisk |
patterns | z.regex(…) | Wire the pattern attribute for screen-reader hints |
touched | Set by formActions.touch or { touch: true } | Guard error display so messages only show after interaction |
Descriptions as labels
Any field annotated with .with(z.describe('...')) exposes its text through formState.descriptions. Use it as the content of a <label> to keep your display text in one place — the schema — rather than duplicated across components.
const schema = z.strictObject({
name: z.formString({ required: true }).with(z.describe('Full Name')),
age: z.formNumber(z.gte(0), z.lte(150)).with(z.describe('Age')),
info: z.object({
email: z.formString().with(z.describe('Email Address')),
}),
});
// Top-level field — direct property access
<label htmlFor="name">{formState.descriptions.name}</label>
// Nested field — use .get() with a path expression
<label htmlFor="email">
{formState.descriptions.get((path) => path.info.email)}
</label>
// Array item field — index is normalised to [0] in descriptions
<label>{formState.descriptions.get((path) => path.notes[0].text)}</label>Ranges wired to input attributes
formState.ranges contains the min/max constraints your schema declares, already typed and ready to pass directly to HTML attributes. There are two range shapes depending on the field type:
| Shape | Field types | Attributes it feeds |
|---|---|---|
type: 'range' | Number, date | min, max on input[type=number] / input[type=date] |
type: 'length' | String, array | minLength, maxLength on input / textarea; or array add/remove guards |
import { useFormState, z, convert } from "form-state";
const schema = z.strictObject({
username: z
.formString(z.minLength(3, 'Too short'), z.maxLength(20, 'Too long'))
.with(z.describe('Username')),
age: z
.formNumber(z.gte(18, 'Must be 18+'), z.lte(120))
.with(z.describe('Age')),
birthDate: z
.formDate({ dateFormat: 'yyyy-MM-dd' }, z.gte(new Date(1900, 0, 1)))
.with(z.describe('Birth Date')),
bio: z
.formString(z.maxLength(500, 'Too long'))
.with(z.describe('Bio')),
tags: z
.formArray(z.string(), { minLength: 1, maxLength: 5 })
.with(z.describe('Tags')),
});
// ── Text input ─────────────────────────────────────────────────
<div>
<label htmlFor="username">{formState.descriptions.username}</label>
<input
id="username"
type="text"
value={formState.data.username}
minLength={formState.ranges.getMin('username')} // 3
maxLength={formState.ranges.getMax('username')} // 20
required={formState.required.username}
onChange={(e) => formActions.change('username', e.target.value)}
onBlur={() => formActions.touch('username')}
/>
</div>
// ── Number input ───────────────────────────────────────────────
<div>
<label htmlFor="age">{formState.descriptions.age}</label>
<input
id="age"
type="number"
value={formState.data.age}
min={formState.ranges.getMin('age')} // 18
max={formState.ranges.getMax('age')} // 120
required={formState.required.age}
onChange={(e) => formActions.change('age', convert.toInt(e.target.value))}
onBlur={() => formActions.touch('age')}
/>
</div>
// ── Date input ─────────────────────────────────────────────────
const rawDate = formState.data.birthDate;
const dateValue = rawDate instanceof Date ? formatDate(rawDate, 'yyyy-MM-dd') : rawDate;
const minDate = formState.ranges.getMin((path) => path.birthDate);
const minAttr = minDate instanceof Date ? formatDate(minDate, 'yyyy-MM-dd') : undefined;
<div>
<label htmlFor="birthDate">{formState.descriptions.birthDate}</label>
<input
id="birthDate"
type="date"
value={dateValue}
min={minAttr}
onChange={(e) => formActions.change('birthDate', convert.toDate(e.target.value, { format: 'yyyy-MM-dd' }))}
onBlur={() => formActions.touch('birthDate')}
/>
</div>
// ── Textarea ───────────────────────────────────────────────────
<div>
<label htmlFor="bio">{formState.descriptions.bio}</label>
<textarea
id="bio"
value={formState.data.bio}
maxLength={formState.ranges.getMax('bio')} // 500
onChange={(e) => formActions.change('bio', e.target.value)}
/>
<small>{formState.data.bio.length} / {formState.ranges.getMax('bio')}</small>
</div>
// ── Array length guard ─────────────────────────────────────────
<button
onClick={() => formActions.array.append('tags', [''])}
disabled={formState.data.tags.length >= (formState.ranges.getMax('tags') ?? Infinity)}
>
Add tag {formState.data.tags.length} / {formState.ranges.getMax('tags')}
</button>required
Fields declared with { required: true } are reflected in formState.required as a static boolean — it never changes at runtime. Use it to drive the HTML required attribute and any visual indicator like an asterisk, keeping both in sync with the schema automatically.
// Top-level field
formState.required.name // true | false
// Nested field
formState.required.get((path) => path.info.age) // true | false
// In JSX — drives the attribute and the visible asterisk together
<label htmlFor="name">
{formState.descriptions.name}
{formState.required.name && <span> *</span>}
</label>
<input
id="name"
type="text"
required={formState.required.name}
value={formState.data.name}
onChange={(e) => formActions.change('name', e.target.value)}
/>patterns
Regex validators added with z.regex() are exposed as raw pattern strings through formState.patterns. Pass them to the native pattern attribute so the browser and screen readers can communicate the constraint — even when noValidate suppresses the browser's own validation UI.
// Top-level field
formState.patterns.id // "^\d+(-\d+)?$" | undefined
// Nested field
formState.patterns.get((path) => path.info.code) // string | undefined
// In JSX
<input
type="text"
value={formState.data.id}
pattern={formState.patterns.id}
title="Must be a number or number range, e.g. 10-20"
onChange={(e) => formActions.change('id', e.target.value)}
/>When a field has multiple z.regex() checks, only the first one is stored in patterns. The title attribute is a good place to describe the full constraint in plain language.
touched
formState.touched tracks whether a user has interacted with a field. Its primary job is to gate error display — you almost always want to show an error only after a field has been touched, not the moment the form mounts.
// Read — top-level and nested
formState.touched.name // boolean
formState.touched.get((path) => path.info.age) // boolean
// Typical pattern: show error only after interaction
{formState.touched.name && formState.errors.name && (
<span role="alert">{formState.errors.name}</span>
)}
// Set on blur (most common)
<input onBlur={() => formActions.touch('name')} />
// Set immediately on change — good for selects and checkboxes
formActions.change((path) => path.category, value, { touch: true });
// Touch a field programmatically without changing its value
formActions.touch('name');
formActions.touch((path) => path.notes[index].text);
// Touch with optional validation
formActions.touch('name', { validate: true });
// Inspect which fields have been touched
formState.touched.getKeys() // ['name', 'info.age', ...]
formStatus.touched // true if any field is touchedWhen editing existing data, pass initialTouched to pre-touch fields so errors are visible immediately without the user having to visit each field first.
Putting it all together — a fully wired field component
Combining descriptions, ranges, required, and patterns into a reusable component shows how much schema-driven metadata FormState gives you for free:
function TextField({ name }: { name: keyof FormSchema }) {
const { formState, formActions, formClasses } = useSchemaFormState();
const label = formState.descriptions[name];
const value = formState.data[name] as string;
const error = formState.errors[name];
const touched = formState.touched[name];
const maxLength = formState.ranges.getMax(name);
const minLength = formState.ranges.getMin(name);
const pattern = formState.patterns[name];
const required = formState.required[name];
return (
<div className={formClasses(name)}>
<label htmlFor={name}>
{label}
{required && <span aria-hidden> *</span>}
</label>
<input
id={name}
type="text"
value={value}
minLength={minLength}
maxLength={maxLength}
pattern={pattern}
required={required}
aria-invalid={touched && Boolean(error)}
aria-describedby={error ? `${name}-error` : undefined}
onChange={(e) => formActions.change(name, e.target.value)}
onBlur={() => formActions.touch(name)}
/>
{touched && error && (
<span id={`${name}-error`} role="alert">{error}</span>
)}
{maxLength && (
<small>{value.length} / {maxLength}</small>
)}
</div>
);
}For date fields, ranges.getMin and ranges.getMax return Date objects. Use formatDate(date, 'yyyy-MM-dd') to convert them to strings suitable for input[type=date].
Array Fields
The formActions.array namespace provides all the operations you need to manage dynamic lists without manual index juggling.
// Append one or more items
formActions.array.append('tags', ['react', 'typescript']);
formActions.array.append((path) => path.notes, [{ text: '', typeId: '' }], { validate: true });
// Insert at a specific position
formActions.array.insert('tags', 0, 'featured');
// Update item at index
formActions.array.update('tags', 1, 'updated-tag');
// Swap two items
formActions.array.swap('tags', 0, 2);
// Remove by index
formActions.array.remove('tags', 0);
// Remove by predicate
formActions.array.remove('tags', (tag) => tag === 'deprecated');
formActions.array.remove((path) => path.notes, (note) => note.text === '');
// Clear the array
formActions.array.clear('tags');Rendering an array field
Use z.symbol() inside array items to get a stable, unique key for each element without maintaining a separate ID array.
const schema = z.strictObject({
notes: z.formArray(
z.object({
uniqueId: z.symbol(), // auto UUID — use as React key
typeId: z.formNumber({ required: true, error: 'Note type is required.' }),
text: z.formString({ required: true }, z.maxLength(100)),
}),
{ minLength: 2, maxLength: 5 }
),
});
// In your component:
{formState.data.notes.map((note, index) => (
<div key={note.uniqueId.toString()}>
<input
value={note.text}
onChange={(e) =>
formActions.change((path) => path.notes[index].text, e.target.value, { touch: true })
}
/>
{formState.errors.get((path) => path.notes[index].text) && (
<span>{formState.errors.get((path) => path.notes[index].text)}</span>
)}
<button onClick={() => formActions.array.remove('notes', index)}>
Remove
</button>
</div>
))}
<button
onClick={() => formActions.array.append('notes', [{ text: '', typeId: '' }])}
disabled={formState.data.notes.length >= formState.ranges.getMax('notes')}
>
Add note
</button>All array methods accept an optional { touch, validate, callback } options object — same interface as formActions.change.
Validation
Validation runs automatically on every change call by default. You control when errors surface to users through the touched pattern: only show an error once the field has been touched.
Validation options
| Option | Default | Description |
|---|---|---|
validateOnMount | false | Run validation immediately when the hook mounts. |
validateOnChange | true | Re-validate after every change call. |
validateOnTouch | false | Run validation when a field is touched. |
validateBeforeSubmit | true | Validate before treating a submit as successful. |
Validating on demand
// Validate the full schema
formActions.validate();
// Validate and mark as submitted on success
formActions.validate({ submit: true });
// Validate with a custom async check (e.g. server-side uniqueness)
formActions.validate(
async () => {
const taken = await api.checkUsername(formState.data.username);
return taken ? { username: 'Username is already taken' } : true;
},
{ submit: true }
);The custom validator receives no arguments — read formState.data directly. Return true for success or a Record<string, string> of field-key: error pairs to inject errors.
Touching fields to reveal errors
Errors are computed on every change, but you typically only show them after the user has interacted with a field. Call touch on blur:
<input
value={formState.data.email}
onChange={(e) => formActions.change('email', e.target.value)}
onBlur={() => formActions.touch('email')}
/>
{/* Only shown after blur */}
{formState.touched.email && formState.errors.email && (
<p className="error">{formState.errors.email}</p>
)}Or pass { touch: true } directly to change when you want to surface errors immediately (common for selects and checkboxes):
// Select / dropdown — touch immediately on pick
formActions.change((path) => path.auto.modelId, selectedId, { touch: true });
// Checkbox — touch and validate on click
formActions.change((path) => path.priority, e.target.checked, { touch: true });
// Radio buttons — top-level string form is fine here
formActions.change('sendEmail', 'yes', { touch: true });Pre-touching fields
Mark fields as touched from the start — useful when editing existing data:
useFormState(schema, {
initialData: existingRecord,
initialTouched: ['name', 'email', (path) => path.info.age],
});Form Submission
formHandlers.handleSubmit(onSubmit, options?) returns an async action you pass to the Form's action prop. It validates the form, calls your handler with a typed SubmitState, and updates formStatus.submitted / formStatus.submitting automatically.
import { type SubmitState } from 'form-state';
const handleSubmit = formHandlers.handleSubmit(
async (submitState: SubmitState<typeof schema>, formData: FormData) => {
// Schema validation failed — return the error map
if (!submitState.valid) {
formActions.focusOnFirstError({ selectText: true });
return submitState.errors;
}
// submitState.data is fully typed and validated here
try {
await fetch('/api/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(submitState.data),
});
return true; // success
} catch (err) {
return { _serverError: 'Something went wrong. Please try again.' };
}
},
{
onSuccess: ({ data, formData }: SubmitSuccessState<typeof schema>) => {
router.push('/dashboard');
},
onError: (state, status) => {
formActions.focusOnFirstError();
},
}
);
return <Form action={handleSubmit}>...</Form>;Focusing the first error
After a failed submission, jump the user to the first broken field:
formActions.focusOnFirstError({ selectText: true });
// Or focus a specific element
formActions.focus('email', { selectText: true });
// Focus only if the field has an error
formActions.focus('email', { errorKey: 'email' });Why use <Form> over a plain <form>
The Form component returned by the hook is a thin wrapper around <form> that wires several behaviours automatically. You can always drop down to a plain <form> — but you'll need to handle each of these yourself.
| Behaviour | <Form> | Plain <form> |
|---|---|---|
| Reset handling | Automatic — dispatches a reset action and re-syncs uncontrolled inputs | Must wire onReset={formHandlers.handleReset} manually |
| React 19 post-submit reset prevention 1 | Includes <FormResetBlocker> automatically — form fields are not cleared after a form action specified in the action prop completes | Must place <FormResetBlocker> inside the <form> manually to support form actions |
| Native validation suppression | Sets noValidate by default — browser popups never appear | Must add noValidate manually or browser bubbles will show |
| Form data cache supression 2 | Sets autoComplete="off" by default | Must add autoComplete="off" manually or browser can cache form data that is inconsistent with the form state data. |
| Enter-key submit prevention | Blocks accidental submit-on-Enter from single-line inputs (opt in with submitWithEnter) | Browser default — Enter submits the form from any input |
useWatch support | Listens to input/change events to feed the internal watch store | formHooks.useWatch will not receive updates |
SecureInput in FormData | Intercepts the formdata event and patches FormData with secure values | SecureInput values will be missing from FormData |
Submit button value in FormData | Tracks event.submitter and ensures the button's name/value is always included | Browser behaviour varies — may omit the submitter value |
- Certain versions of React and Next.js reset uncontrolled form controls after a server action completes. Because FormState manages all field values internally, this reset is never desirable — the fields would momentarily revert to their HTML defaults before FormState can restore them.
<FormResetBlocker>intercepts and cancels that reset at the DOM level.
- The
autoCompleteattribute/prop can still be set on individual input elements/components.
Using a plain <form> with onSubmit
If you prefer the traditional onSubmit pattern — or if you're integrating into an existing form element you don't control — use formActions.validate directly and wire reset and submit manually.
const { formState, formStatus, formActions, formHandlers } = useFormState(schema);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
formActions.validate(
async () => {
// Optional: async cross-field or server-side check
const taken = await api.checkEmail(formState.data.email);
return taken ? { email: 'Email already registered' } : true;
},
{
submit: true,
callback: (state, status) => {
if (status.valid) {
api.save(schema.toObject(state.data));
} else {
formActions.focusOnFirstError({ selectText: true });
}
},
}
);
};
return (
<form
noValidate
onSubmit={handleSubmit}
onReset={formHandlers.handleReset}
>
<input
value={formState.data.name}
onChange={(e) => formActions.change('name', e.target.value)}
onBlur={() => formActions.touch('name')}
/>
{formState.errors.name && <span>{formState.errors.name}</span>}
<button type="submit" disabled={formStatus.submitting}>Save</button>
<button type="reset">Reset</button>
</form>
);With a plain <form>, formHooks.useWatch will not work and SecureInput values will not appear in FormData. If you need either of those, use the Form component.
Working with FormData
The second argument to your handleSubmit callback — and the formData property on SubmitSuccessState — is the browser's native FormData built from the form elements at submission time. It contains every named field in the form, including entries that the Zod schema doesn't know about (hidden inputs, extra metadata fields).
Keys in FormData match the HTML name attributes on your inputs. For nested paths generated with inferName the key follows whichever notation was used — bracket by default:
// name="email"
// formData.get('email')
// name={inferName(path => path.info.age)}
// formData.get('info["age"]')
// name={inferName(path => path.tags[0], 'dot')}
// formData.get('tags.0')
const onSubmit = async (state: SubmitState<FormSchema>, formData: FormData) => {
if (!state.valid) return state.errors;
// Raw string from FormData — useful for fields outside the schema
const redirectTo = formData.get('redirectTo') as string | null;
const age = formData.get('info["age"]') as string | null;
await api.save(state.data);
return true;
};Submitter tracking
When a form has multiple submit buttons with different name/value pairs, the browser is supposed to include the clicked button's value in FormData — but behaviour varies across browsers and async action forms. The Form component solves this by tracking event.submitter during the capture phase and patching FormData if the button's entry is missing.
// Two submit buttons with the same name but different values
<Form action={handleSubmit}>
<button name="action" value="save">Save draft</button>
<button name="action" value="publish">Publish</button>
</Form>
// In your handler — formData always carries the clicked button's value
const onSubmit = async (state: SubmitState<FormSchema>, formData: FormData) => {
if (!state.valid) return state.errors;
const action = formData.get('action'); // 'save' | 'publish'
if (action === 'publish') {
await api.publish(state.data);
} else {
await api.saveDraft(state.data);
}
return true;
};Submitter tracking only works with the Form component. A plain <form> relies on native browser behaviour, which may omit the button value in async action forms.
useFormStatus — submission spinner
Because FormState uses a real <form action>, React 19's useFormStatus works without any extra wiring. Create a child component that reads pending and renders a loading overlay:
import { useFormStatus } from 'react-dom';
// Must be a child of the <form> element — not the same component
export function FormMask() {
const { pending } = useFormStatus();
if (!pending) return null;
return (
<div
role="status"
aria-label="Saving…"
style={{
position: 'absolute',
inset: 0,
display: 'grid',
placeItems: 'center',
background: 'rgba(0,0,0,0.4)',
zIndex: 10,
}}
>
<Spinner />
</div>
);
}
// Use it inside <Form>
<Form action={action} style={{ position: 'relative' }}>
<FormMask />
{/* ... */}
</Form>useFormStatus must be called inside a component that is a descendant of the <form>, not the component that renders the form itself. FormState's Form component satisfies this requirement when you nest your spinner inside it.
formStatus.submitting vs React useFormStatus
Both serve similar purposes but at different levels:
formStatus.submitting | useFormStatus().pending | |
|---|---|---|
| Source | FormState internal state | React 19 form context |
| Where to use | Same component as useFormState | Any child component inside <Form> |
| Use case | Disabling the submit button | Showing a spinner, blocking inputs |
Encoding FormData as a URL string
Use formDataEncode to convert a FormData instance into a URLSearchParams object — handy for building query strings or application/x-www-form-urlencoded request bodies. File entries are replaced with the file's name.
import { formDataEncode } from 'form-state';
const onSubmit = async (state: SubmitState<FormSchema>, formData: FormData) => {
if (!state.valid) return state.errors;
// Encode all entries as a URL-encoded string
const body = formDataEncode(formData).toString();
// "name=Alice&info%5B%22age%22%5D=30" (bracket notation — useFormState default)
// "name=Alice&info.age=30" (dot notation — pass 'dot' as nameFormat arg,
// or use inferName(path, 'dot'))
// Omit specific fields (e.g. internal tracking values)
const bodyClean = formDataEncode(formData, ['csrf', 'action']).toString();
await fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
return true;
};Manual Errors
Set errors outside of schema validation — server errors, async uniqueness checks, or cross-field constraints — using formActions.setError.
// Set by field key
formActions.setError('email', 'This email address is already registered');
// Set by path expression — nested field inside an array item
formActions.setError((path) => path.notes[index].text, '666 is not allowed as note text');
// Set a form-level error (arbitrary string key)
formActions.setError('_serverError', 'Something went wrong. Please try again.');
// Clear a specific manual error (pass null, undefined, or call with one argument)
formActions.setError('email', null);
formActions.setError((path) => path.notes[index].text); // clears it
// Clear all manual errors
formActions.clearManualErrors();
// Clear only errors matching a predicate
formActions.clearManualErrors({
predicate: (key) => key.startsWith('_'),
});Read manual errors with errors.getManual(key):
{formState.errors.getManual('_serverError') && (
<div className="form-error">
{formState.errors.getManual('_serverError')}
</div>
)}Manual errors live alongside schema errors. formStatus.valid is false when either type is present; formStatus.validSchema reflects only the Zod schema result.
Readonly & Disabled
FormState tracks three modes: 'editable', 'readOnly', and 'disabled'. Switch between them at any time with formActions.setMode(). The formStatus flags (readOnly, disabled) make it easy to drive conditional rendering.
// Set initial mode
useFormState(schema, { initialMode: 'readOnly' });
// Switch at runtime
formActions.setMode('editable');
formActions.setMode('readOnly');
formActions.setMode('disabled');Rendering alternate UI in readonly mode
A common pattern is to render a plain text input in readonly mode and hide interactive controls like remove buttons or add links.
const { formState, formStatus, formActions } = useSchemaFormState();
const { readOnly } = formStatus;
// ── Select / dropdown ──────────────────────────────────────────
{readOnly ? (
<input
type="text"
readOnly
tabIndex={-1}
value={noteTypes.find((noteType) => noteType.value === note.typeId)?.label ?? ''}
autoComplete="off"
/>
) : (
<Select
options={noteTypes}
value={note.typeId}
onChange={(opt) =>
formActions.change((path) => path.notes[index].typeId, opt?.value ?? '', { touch: true })
}
/>
)}
// ── Checkbox ───────────────────────────────────────────────────
{readOnly ? (
<input
type="text"
readOnly
tabIndex={-1}
value={data.priority === true ? 'Yes' : data.priority === false ? 'No' : 'N/A'}
autoComplete="off"
/>
) : (
<input
id="priority"
type="checkbox"
checked={Boolean(data.priority)}
onChange={(e) => formActions.change((path) => path.priority, e.target.checked, { touch: true })}
/>
)}
// ── Radio buttons ──────────────────────────────────────────────
{readOnly ? (
<input type="text" readOnly tabIndex={-1} value={data.sendEmail === 'yes' ? 'Yes' : 'No'} />
) : (
<span role="group">
<label>
<input type="radio" value="yes"
onChange={() => formActions.change('sendEmail', 'yes', { touch: true })} />
Yes
</label>
<label>
<input type="radio" value="no"
onChange={() => formActions.change('sendEmail', 'no', { touch: true })} />
No
</label>
</span>
)}
// ── Array controls ─────────────────────────────────────────────
{!readOnly && (
<button onClick={() => formActions.array.remove((path) => path.notes, index)}>
Remove
</button>
)}
{!readOnly && (
<button onClick={() => formActions.array.append('notes', [emptyNote])}>
Add note
</button>
)}Disabled via <fieldset>
The native disabled attribute on a <fieldset> disables all descendant controls at the browser level. Wire it to formStatus.disabled for a single, zero-CSS approach.
<Form
action={formHandlers.handleSubmit(onSubmit)}
data-readonly={formStatus.readOnly} // CSS hook for styling
>
<fieldset disabled={formStatus.disabled}>
<IdInput />
<NoteInputs />
<AutoSelects />
{!formStatus.readOnly && <Footer />}
</fieldset>
</Form>Mode switcher example
import { type FormMode } from 'form-state';
function ModeSelector({
mode,
onChange,
}: {
mode: FormMode;
onChange: (mode: FormMode) => void;
}) {
return (
<div>
{(['editable', 'readOnly', 'disabled'] as FormMode[]).map((option) => (
<label key={option}>
<input
type="radio"
checked={mode === option}
onChange={() => onChange(option)}
/>
{option}
</label>
))}
</div>
);
}
// In parent:
<ModeSelector
mode={formStatus.mode}
onChange={(m) => formActions.setMode(m)}
/>CSS Classes
formClasses(field) returns a space-separated class string based on the field's current state. Wire it to any element — label, wrapper, or input.
// Generates e.g.: "form-state__error form-state__touched form-state__required"
<div className={formClasses('name')}>
<label>Name</label>
<input ... />
<span>{formState.errors.name}</span>
</div>
// Arrow function path expression — works for nested fields too
<label className={formClasses((path) => path.info.name)}>
{formState.descriptions.get((path) => path.info.name)}
</label>
// Custom prefix per-call
formClasses('name', undefined, { prefix: 'my-form' })
// "my-form__error my-form__required"CSS class names
| Class | Applied when |
|---|---|
[prefix]__error | Field has a validation error. |
[prefix]__touched | Field has been touched. |
[prefix]__required | Field is required per schema. |
Override the prefix globally:
useFormState(schema, { CSSPrefix: 'my-form' });Context & Provider
When a form spans multiple components, use the Context API instead of prop-drilling. FormState provides a FormStateProvider, a useFormStateContext hook, and a formConnect HOC.
Recommended pattern — shared accessor hook
Export a typed wrapper around useFormStateContext once and import it everywhere in your form tree. This avoids passing the schema reference around and produces more focused component imports.
// app-schema.ts
import { useFormStateContext, z } from 'form-state';
export const formSchema = z.strictObject({ /* ... */ });
export type FormSchema = z.infer<typeof formSchema>;
// One typed hook — used everywhere in the form tree
export const useSchemaFormState = () => useFormStateContext(formSchema);// NameField.tsx — leaf component, no props needed
import { useSchemaFormState } from './app-schema';
export function NameField() {
const { formState, formActions, formClasses } = useSchemaFormState();
return (
<div>
<label className={formClasses((path) => path.info.name)}>
{formState.descriptions.get((path) => path.info.name)}
</label>
<input
type="text"
value={formState.data.info.name}
maxLength={formState.ranges.getMax((path) => path.info.name)}
onChange={(e) => formActions.change((path) => path.info.name, e.target.value)}
onBlur={() => formActions.touch((path) => path.info.name)}
/>
{formState.errors.get((path) => path.info.name) && (
<p>{formState.errors.get((path) => path.info.name)}</p>
)}
</div>
);
}// AppForm.tsx — root form component wrapped with formConnect
import { formConnect } from 'form-state';
import { formSchema } from './app-schema';
function AppForm({ onSubmitted }: { onSubmitted: () => void }) {
const { Form, formHandlers } = useSchemaFormState();
return (
<Form action={formHandlers.handleSubmit(onSubmit, { onSuccess: onSubmitted })}>
<fieldset>
<NameField />
<AgeField />
</fieldset>
<button type="submit">Save</button>
</Form>
);
}
export default formConnect({
schema: formSchema,
confirmDirtyStateNavigation: true, // warn on unsaved navigation
watch: true, // enable useWatch
})(AppForm);useFormStateContext must receive the same schema reference that was passed to the provider. A mismatched schema throws at runtime in development.
SecureInput
SecureInput is a drop-in password component that never stores the real value in the DOM. It shows bullets visually but passes the actual text only through controlled callbacks, preventing clipboard sniffing and browser auto-fill exfiltration.
import { SecureInput } from 'form-state';
<SecureInput
type="password"
onSecureChange={(value) => {
// 'value' is the real plaintext — never touches the DOM
formActions.change('password', value);
}}
onSecureBlur={(value) => {
formActions.change('password', value, { touch: true });
}}
/>Features:
- Displays masked bullets, real value stays in memory only.
- Blocks copy, cut, paste, and drag to prevent leakage.
- Ctrl+Z / Ctrl+Y undo protection.
- Compatible with standard
onChangefor uncontrolled usage.
Selectors
formHooks.useSelector is a hook that derives a memoized value from form state. Pass an array of input selectors and a result function — the result is only recomputed when one of the inputs changes by reference. The returned selector function is stable across renders and can be called with any state snapshot.
Simple selectors
A selector with a single input extracts or transforms one slice of state. The state type is inferred automatically from the schema — no manual annotation needed.
const { formState, formHooks } = useFormState(schema);
// Extract a field as-is
const selectName = formHooks.useSelector(
state => state.name,
name => name
);
// Derive a boolean from a field
const selectIsAdult = formHooks.useSelector(
state => state.age,
age => typeof age === 'number' && age >= 18
);
selectName(formState.data) // string
selectIsAdult(formState.data) // booleanComposable selectors
Any selector returned by useSelector can be used as an input to another useSelector call. Each step is independently memoized — a downstream selector only reruns when its upstream output actually changes.
// Filters the items array — reruns only when items changes
const selectActiveItems = formHooks.useSelector(
state => state.items,
items => items.filter(item => item.active)
);
// Counts the filtered list — reruns only when selectActiveItems output changes
const selectActiveCount = formHooks.useSelector(
selectActiveItems,
items => items.length
);
// Maps names — also reads from the already-filtered list
const selectActiveNames = formHooks.useSelector(
selectActiveItems,
items => items.map(item => item.name)
);
selectActiveCount(formState.data) // number
selectActiveNames(formState.data) // string[]Selectors with multiple inputs
Pass multiple input selectors to combine values from different parts of the state. The result function receives each extracted value as a separate, typed argument.
const schema = z.strictObject({
orders: z.array(
z.object({
product: z.formString(),
quantity: z.formNumber(),
unitPrice: z.formNumber(),
shipped: z.formBoolean(),
})
),
discountPct: z.formNumber(),
});
const { formState, formHooks } = useFormState(schema);
// Select unshipped orders
const selectPendingOrders = formHooks.useSelector(
state => state.orders,
orders => orders.filter(order => !order.shipped)
);
// Combine the filtered list with the discount to compute an order summary
const selectOrderSummary = formHooks.useSelector(
[selectPendingOrders, state => state.discountPct], // array of selectors
(pending, discountPct) => {
const subtotal = pending.reduce((sum, o) => sum + o.quantity * o.unitPrice, 0);
const discount = typeof discountPct === 'number' ? discountPct / 100 : 0;
return {
count: pending.length,
subtotal,
total: subtotal * (1 - discount),
};
}
);
const summary = selectOrderSummary(formState.data);
// { count: number; subtotal: number; total: number }selectOrderSummary only recomputes when either pending (the output of selectPendingOrders) or discountPct changes — not on every render.
The useSelector API is intentionally compatible with reselect — selectors written here can be swapped for reselect selectors and vice versa.
Debounced Changes
Pass debounceIntervalMs to change to delay downstream effects — useful for search inputs, async field validation, or expensive re-renders.
formActions.change('search', inputValue, {
debounceIntervalMs: 400,
callback: (state, status) => {
// Fires 400ms after the last keystroke
performSearch(state.data.search);
},
});
// Arrow function path — nested field
formActions.change((path) => path.notes[index].text, e.target.value, {
debounceIntervalMs: 500,
touch: true,
callback: (state: FormState<FormSchema>) => {
// Custom async check after user stops typing
if (state.data.notes[index].text === '666') {
formActions.setError((path) => path.notes[index].text, 'Not allowed');
} else {
formActions.setError((path) => path.notes[index].text);
}
},
});The callback always receives the latest (state, status) snapshot — even when multiple rapid changes fired before the debounce elapsed.
State Listener
Subscribe to form state changes from inside a component without triggering extra re-renders.
The listener must be a stable reference — define it outside the component or wrap it in useCallback.
import { type StateChangeListener } from 'form-state';
const listener: StateChangeListener<FormSchema> = ({ type, data }) => {
console.info(type, data);
};
function AppForm() {
formHooks.useListener(listener);
// ...
}The event object:
| Property | Type | Description |
|---|---|---|
type | 'change' | 'submit' | What triggered the event. |
data | T | Current form data snapshot. |
formData | FormData? | Native FormData — only on submit events. |
submitCount | number | How many times the form has been submitted. |
errors | Record<string, string> | Current error map. |
useWatch
formHooks.useWatch(name, compute?) observes the raw DOM value of a named input as the user types — without needing a controlled value prop. Requires watch: true in the hook options.
// Enable watching at the hook / formConnect level
useFormState(schema, { watch: true });
// or: formConnect({ schema, watch: true })(MyForm)
// Watch a field by its inferName value, with an optional transform
const idValue = formHooks.useWatch(
formActions.inferName((path) => path.id),
(value) => value.trim()
);
// Use the live value to drive conditional rendering
{/^666(-\d*)?$/.test(idValue) && <DevilIcon />}
// Watch with a more complex transform — only keep valid values
const cleanId = formHooks.useWatch(
formActions.inferName((path) => path.id),
(val) => (/^\d+$|^(\d+-\d+)$/.test(val) ? val : '')
);useWatch reads the uncontrolled DOM value — it's most useful for inputs where you want to react to intermediate keystrokes without updating form state on every character, such as driving a live preview or badge next to the field.
inferName
formActions.inferName generates the correct HTML name attribute value for a field, including nested paths. Use this when you need the browser's native FormData to carry the right keys — and when combining FormState with useWatch.
Two name formats
Nested paths can be serialised in two formats. The format you choose determines how the key appears in FormData when the form is submitted.
| Format | Path info.age | Array item items[0].title | Top-level name |
|---|---|---|---|
'bracket' (default) | info["age"] | items[0]["title"] | name |
'dot' | info.age | items.0.title | name |
When you call formData.get(key) after submission, use the same format that was used for the name attribute. If you mix formats across fields, read each key with the matching notation.
// Bracket format (default) — infers: info["age"]
formActions.inferName((path) => path.info.age)
// Dot format — infers: info.age
formActions.inferName((path) => path.info.age, 'dot')
// Top-level string — always just the key itself
formActions.inferName('name')
// Uncontrolled input + useWatch combo
<input
name={formActions.inferName((path) => path.id)}
defaultValue={formState.data.id}
onChange={(e) => formActions.change((path) => path.id, e.target.value)}
/>
// useWatch subscribes by the same name string
const watched = formHooks.useWatch(formActions.inferName((path) => path.id));
// Reading back from FormData on submit
const onSubmit = async (state: SubmitState<FormSchema>, formData: FormData) => {
// bracket key (default format)
formData.get('info["age"]'); // value of info.age
// dot key
formData.get('info.age'); // only if that field used 'dot' format
};Change the default format for all fields globally:
const { Form, formActions } = useFormState(schema, {
inferredNameFormat: 'dot', // all inferName calls default to dot notation
});Value Helpers
The convert export provides utilities for coercing raw form strings into typed values when wiring native inputs that always return strings as well as converting from optional to concrete types.
import { convert } from 'form-state';
// Safe numeric parsing — returns the number or '' if invalid
convert.toInt('42') // 42
convert.toFloat('3.14') // 3.14
convert.toInt('abc') // ''
// Optional number to number type conversion
convert.asNumber(1); // 1
convert.asNumber(''); // 0
convert.asNumber('', 100.0); // 100.0
// Date parsing with format string
convert.toDate('12/31/2024', { format: 'MM/dd/yyyy' }) // Date object
// Optional boolean to boolean type conversion
convert.asBoolean(true); // true
convert.asBoolean('', false); // false
convert.asBoolean('', true); // true
// Boolean coercion
convert.toBoolean('true') // true
convert.toBoolean('0') // false
// Validated literal — returns the value or '' if not in set
convert.toLiteral('active', ['active', 'inactive']) // 'active'
convert.toLiteral('other', ['active', 'inactive']) // ''Date formatting
import { formatDate } from 'form-state';
const date = formState.data.birthDate; // Date | ''
if (date instanceof Date) {
formatDate(date, 'MM/dd/yyyy') // '12/31/2024'
formatDate(date, 'yyyy-MM-dd') // '2024-12-31'
}Date parsing
import { safeParseDate } from 'form-state';
const { success: startDateSuccess, date: startDate } = safeParseDate('2020-12-01'); // yyyy-MM-dd
// startDateSuccess: true
// startDate: new Date(2020, 11, 1)
const { date: endDate } = safeParseDate('12/31/2020', 'MM/dd/yyyy');
const { success: finalDateSuccess, date: finalDate } = safeParseDate('abcd1234', 'MM/dd/yyyy');
// finalDateSuccess: false
// finalDate: nullDirty flag for non-field state
Track external state (e.g. a file upload) alongside form state using formActions.setDirty. Keys must start with #.
// Mark a file as pending upload
formActions.setDirty('#avatar', true);
formState.dirty.get('#avatar') // true
formStatus.dirty // true (includes #avatar)
// Clear after upload completes
formActions.setDirty('#avatar', false);Parsing external data
Use parseState to validate and coerce API responses before loading them into the form — useful when server data might be partially invalid.
import { parseState } from 'form-state';
const result = parseState(formSchema, apiResponse);
if (result.success) {
formActions.replace(result.data, { validate: true });
} else {
// result.errors.getAll() — array of validation messages
// result.errors.getKeys() — failed field paths
console.warn('API data has validation issues:', result.errors.getAll().join(' | '));
// Still use the coerced data if errors are non-critical
formActions.replace(result.data);
}Next.js
FormState works naturally with the Next.js App Router. Mark the form component "use client" and call a Server Action from inside handleSubmit — validation runs on the client, the action receives clean, typed data.
Server action
"use server";
import { parseState, z } from "form-state";
import { schema } from "./contact-form";
export async function submitContact(formData: z.infer<typeof schema>): Promise<void> {
const result = parseState(schema, formData, true);
if (!result.success) {
throw new Error(result.errors.getAll().join(" | "));
}
await db.contacts.create({ data: result.data });
}Form component
"use client";
import { useFormState, z } from "form-state";
import { submitContact } from "./actions";
const schema = z.object({
name: z.formString({ required: true, error: "Name is required" }),
email: z.formString(
{ required: true, error: "Email is required" },
z.regex(z.regexes.email, "Enter a valid email"),
),
message: z.formString(
{ required: true, error: "Message is required" },
z.minLength(10, "Must be at least 10 characters"),
),
agreeToTerms: z.formBoolean({ required: true, error: "Agreement is required" })
.check(z.validate((v) => v === true, "You must agree to continue")),
});
export default function ContactForm() {
const {
formState: { data, errors },
formStatus: { dirty, submitting },
formActions,
formHandlers,
Form,
} = useFormState(schema);
const handleSubmit = formHandlers.handleSubmit(async (state) => {
if (!state.valid) {
formActions.focusOnFirstError({ selectText: true });
return {};
}
try {
await submitContact(state.data);
return true;
} catch (ex) {
return { _serverError: ex instanceof Error ? ex.message : "Server error" };
}
});
return (
<Form action={handleSubmit}>
{errors.getManual("_serverError") && (
<p className="error">{errors.getManual("_serverError")}</p>
)}
<label>
Name
<input
type="text"
name={formActions.inferName("name")}
defaultValue={data.name}
onBlur={(e) => formActions.change("name", e.target.value, { touch: true })}
/>
{errors.name && <span>{errors.name}</span>}
</label>
<label>
Email
<input
type="email"
name={formActions.inferName("email")}
defaultValue={data.email}
onBlur={(e) => formActions.change("email", e.target.value, { touch: true })}
/>
{errors.email && <span>{errors.email}</span>}
</label>
<label>
Message
<textarea
name={formActions.inferName("message")}
defaultValue={data.message}
onBlur={(e) => formActions.change("message", e.target.value, { touch: true })}
/>
{errors.message && <span>{errors.message}</span>}
</label>
<label>
<input
type="checkbox"
name={formActions.inferName("agreeToTerms")}
defaultChecked={data.agreeToTerms === true}
onChange={(e) => formActions.change("agreeToTerms", e.target.checked, { touch: true })}
/>
I agree to the terms
{errors.agreeToTerms && <span>{errors.agreeToTerms}</span>}
</label>
<button type="submit" disabled={submitting}>
{submitting ? "Sending…" : "Send"}
</button>
<button type="reset" disabled={!dirty}>
Reset
</button>
</Form>
);
}Pass state.data directly to the server action — parseState strips FormState metadata on the server side.
Pre-filling from a server component
A Server Component can fetch data and forward it as initialData to seed the form before it reaches the client — useful for pre-filling fields from a user profile or a saved draft.
// app/contact/page.tsx
import { parseState } from "form-state";
import ContactForm, { schema } from "./contact-form";
export default async function ContactPage() {
const user = await db.users.findFirst({ where: { id: await getSessionUserId() } });
const result = parseState(schema, user);
if (!result.success) {
throw new TypeError("Unexpected user data shape");
}
return <ContactForm user={result.data} />;
}Update ContactForm to accept and forward the prop:
"use client";
import { useFormState, z } from "form-state";
import { submitContact } from "./actions";
// schema definition unchanged …
export default function ContactForm({
user,
}: {
user?: z.infer<typeof schema>;
}) {
const {
formState: { data, errors },
formStatus: { dirty, submitting },
formActions,
formHandlers,
Form,
} = useFormState(schema, { initialData: user });
// … rest of the component unchanged
}React Router 7 (Remix)
FormState works naturally with React Router 7 (Remix). The form component runs entirely on the client — validation stays in the browser, and on success the form POSTs typed data to a route action via fetch.
Route action
// app/routes/contact.ts
import type { Route } from "./+types/contact";
import { parseState, z } from "form-state";
import { schema } from "../contact-form";
export async function action({ request }: Route.ActionArgs) {
const { data: formData } = await request.json() as { data: z.infer<typeof schema> };
const result = parseState(schema, formData, true);
if (!result.success) {
return Response.json({ error: result.errors.getAll().join(" | ") }, { status: 400 });
}
await db.contacts.create({ data: result.data });
return { success: true };
}Form component
import { useFormState, z } from "form-state";
const schema = z.object({
name: z.formString({ required: true, error: "Name is required" }),
email: z.formString(
{ required: true, error: "Email is required" },
z.regex(z.regexes.email, "Enter a valid email"),
),
message: z.formString(
{ required: true, error: "Message is required" },
z.minLength(10, "Must be at least 10 characters"),
),
agreeToTerms: z.formBoolean({ required: true, error: "Agreement is required" })
.check(z.validate((v) => v === true, "You must agree to continue")),
});
export default function ContactForm() {
const {
formState: { data, errors },
formStatus: { dirty, submitting },
formActions,
formHandlers,
Form,
} = useFormState(schema);
const handleSubmit = formHandlers.handleSubmit(async (state) => {
if (!state.valid) {
formActions.focusOnFirstError({ selectText: true });
return {};
}
const res = await fetch("/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: state.data }),
});
if (!res.ok) {
const error = await res.json() as Error;
return { _serverError: error?.message ?? "Server error" };
}
return true;
});
return (
<Form action={handleSubmit}>
{errors.getManual("_serverError") && (
<p className="error">{errors.getManual("_serverError")}</p>
)}
<label>
Name
<input
type="text"
name={formActions.inferName("name")}
defaultValue={data.name}
onBlur={(e) => formActions.change("name", e.target.value, { touch: true })}
/>
{errors.name && <span>{errors.name}</span>}
</label>
<label>
Email
<input
type="email"
name={formActions.inferName("email")}
defaultValue={data.email}
onBlur={(e) => formActions.change("email", e.target.value, { touch: true })}
/>
{errors.email && <span>{errors.email}</span>}
</label>
<label>
Message
<textarea
name={formActions.inferName("message")}
defaultValue={data.message}
onBlur={(e) => formActions.change("message", e.target.value, { touch: true })}
/>
{errors.message && <span>{errors.message}</span>}
</label>
<label>
<input
type="checkbox"
name={formActions.inferName("agreeToTerms")}
defaultChecked={data.agreeToTerms === true}
onChange={(e) => formActions.change("agreeToTerms", e.target.checked, { touch: true })}
/>
I agree to the terms
{errors.agreeToTerms && <span>{errors.agreeToTerms}</span>}
</label>
<button type="submit" disabled={submitting}>
{submitting ? "Sending…" : "Send"}
</button>
<button type="reset" disabled={!dirty}>
Reset
</button>
</Form>
);
}Unlike Next.js Server Actions, Remix route actions receive a standard Request. Posting JSON keeps the handler simple and the response shape entirely in your control.
Pre-filling from a loader
Export a loader from the route file to fetch data on the server and pass it as initialData — for example, pre-filling fields from the current user's profile.
// app/routes/contact.tsx
import type { Route } from "./+types/contact";
import { parseState } from "form-state";
import ContactForm, { schema } from "../contact-form";
export async function loader() {
const user = await db.users.findFirst({ where: { id: await getSessionUserId() } });
const result = parseState(schema, user);
if (!result.success) {
throw new Response("Unexpected user data shape", { status: 500 });
}
return result.data;
}
export default function ContactRoute({ loaderData }: Route.ComponentProps) {
return <ContactForm user={loaderData} />;
}Update ContactForm to accept and forward the prop:
import { useFormState, z } from "form-state";
// schema definition unchanged …
export default function ContactForm({
user,
}: {
user?: z.infer<typeof schema>;
}) {
const {
formState: { data, errors },
formStatus: { dirty, submitting },
formActions,
formHandlers,
Form,
} = useFormState(schema, { initialData: user });
// … rest of the component unchanged
}TanStack Query Integration
A common pattern is to fetch a record from the server, seed it into a form, let the user make changes, and save via a mutation. TanStack Query's useSuspenseQuery and useMutation compose cleanly with useFormState — query data flows into initialData, and mutateAsync fits naturally inside the handleSubmit callback.
npm install @tanstack/react-queryComplete example
import { useSuspenseQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useFormState, parseState, z, type SchemaDataObject, type SubmitState } from 'form-state';
const DATE_FORMAT = 'MM/dd/yyyy';
const profileSchema = z.strictObject({
name: z.formString({ required: true }).with(z.describe('Name')),
email: z.formString({ required: true }).with(z.describe('Email')),
dateOfBirth: z.formDate({ dateFormat: DATE_FORMAT }).with(z.describe('Date of Birth')),
bio: z.formString().with(z.describe('Bio')),
});
// Inferred form state shape TypeScript type
type ProfileData = z.infer<typeof profileSchema>;
// Derived API type — empty literals replaced with `undefined`, optional fields use `?`
type ProfileApiData = SchemaDataObject<ProfileData>;
// Your fetch layer — swap for any HTTP client
declare const api: {
getProfile(userId: string): Promise<ProfileApiData>;
updateProfile(userId: string, values: ProfileApiData): Promise<void>;
};
function FormError({ error }: { error?: string }) {
return error ? <span className="field-error">{error}</span> : null;
}
function ProfileForm({ userId }: { userId: string }) {
const queryClient = useQueryClient();
// 1. Fetch and validate current server data
const { data } = useSuspenseQuery({
queryKey: ['profile', userId],
queryFn: async () => {
const apiData = await api.getProfile(userId);
// Validate the API response against the schema.
const result = parseState(profileSchema, apiData);
if (result.success) {
return result.data;
} else {
throw new Error(result.errors.getAll().join(' | '));
}
},
});
// 2. Seed the form — confirmDirtyStateNavigation prompts before unsaved navigation
const { Form, formState, formStatus, formActions, formHandlers } =
useFormState(profileSchema, {
initialData: data,
confirmDirtyStateNavigation: true,
});
// 3. Mutation for saving
const { mutateAsync } = useMutation({
mutationFn: (values: ProfileApiData) => api.updateProfile(userId, values),
});
// 4. Submit handler — mutateAsync lives inside the async callback
const handleSubmit = formHandlers.handleSubmit(
async (submitState: SubmitState<typeof profileSchema>) => {
if (!submitState.valid) {
return submitState.errors;
}
try {
await mutateAsync(profileSchema.toObject(submitState.data));
return true;
} catch (err) {
return { _serverError: err instanceof Error ? err.message : 'Save failed.' };
}
},
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile', userId] });
},
}
);
return (
<Form action={handleSubmit}>
<FormError error={formState.errors.getManual('_serverError')} />
<label>
{formState.descriptions.name}
{formState.required.name && <span> *</span>}
<input
value={formState.data.name}
required={formState.required.name}
onChange={(e) => formActions.change('name', e.target.value)}
onBlur={() => formActions.touch('name')}
/>
<FormError error={formState.errors.name} />
</label>
<label>
{formState.descriptions.email}
{formState.required.email && <span> *</span>}
<input
type="email"
value={formState.data.email}
required={formState.required.email}
onChange={(e) => formActions.change('email', e.target.value)}
onBlur={() => formActions.touch('email')}
/>
<FormError error={formState.errors.email} />
</label>
<label>
{formState.descriptions.dateOfBirth}
<input
type="text"
placeholder={DATE_FORMAT}
value={formState.data.dateOfBirth}
onChange={(e) => formActions.change('dateOfBirth', e.target.value)}
onBlur={() => formActions.touch('dateOfBirth')}
/>
<FormError error={formState.errors.dateOfBirth} />
</label>
<label>
{formState.descriptions.bio}
<textarea
value={formState.data.bio ?? ''}
onChange={(e) => formActions.change('bio', e.target.value)}
onBlur={() => formActions.touch('bio')}
/>
</label>
<button type="submit" disabled={formStatus.submitting}>Save Changes</button>
</Form>
);
}
// Wrap at the call site — Suspense handles loading, ErrorBoundary handles fetch errors
function ProfilePage({ userId }: { userId: string }) {
return (
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<Skeleton />}>
<ProfileForm userId={userId} />
</Suspense>
</ErrorBoundary>
);
}Seeding initialData from a query
With useSuspenseQuery, data is always defined — the component suspends until the query resolves, so useFormState always receives the full server value on first render.
const { data } = useSuspenseQuery({
queryKey: ['profile', userId],
queryFn: () => api.getProfile(userId),
});
// data is always ProfileData here — the component suspends until resolved
useFormState(profileSchema, { initialData: data });initialData only updates non-dirty fields. Once a user edits a field it is marked dirty and subsequent query revalidations will not overwrite their input. Background refetches and cache invalidations are therefore safe — they sync server-computed fields without clobbering in-progress edits.
Validating fetched data against the schema
Use parseState inside queryFn to validate the API response before it reaches the form. If validation fails, the thrown error bubbles to the nearest <ErrorBoundary> — no form state is ever touched.
import { parseState } from 'form-state';
const { data } = useSuspenseQuery({
queryKey: ['profile', userId],
queryFn: async () => {
const apiData = await api.getProfile(userId);
const result = parseState(profileSchema, apiData);
if (result.success) {
return result.data;
} else {
throw new Error(result.errors.getAll().join(' | '));
}
},
});Handling loading and errors
Because useSuspenseQuery suspends while loading and throws on error, both states are handled outside the component by wrapping it with <Suspense> and an <ErrorBoundary>:
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<Skeleton />}>
<ProfileForm userId={userId} />
</Suspense>
</ErrorBoundary>Inside ProfileForm, data is always defined — no isPending or isError guards needed.
If you prefer conditional rendering over Suspense and ErrorBoundary, swap useSuspenseQuery for useQuery and handle isPending and isError directly inside the component.
Calling a mutation on submit
mutateAsync returns a promise, so it drops straight into the async handleSubmit callback. The submitState.data passed to the callback is fully typed and validated — pass it directly to your mutation function.
const { mutateAsync } = useMutation({
mutationFn: (values: ProfileApiData) => api.updateProfile(userId, values),
});
const handleSubmit = formHandlers.handleSubmit(
async (submitState: SubmitState<typeof profileSchema>) => {
// Abort and display schema errors if client-side validation failed
if (!submitState.valid) return submitState.errors;
try {
await mutateAsync(profileSchema.toObject(submitState.data));
return true; // signals success to formStatus
} catch (err) {
// Returning an object surfaces it as a manual error in the form
return { _serverError: err instanceof Error ? err.message : 'Save failed.' };
}
}
);Invalidating the cache after a save
Use the onSuccess option to invalidate the query after a successful submission. This triggers a background refetch so any server-computed fields — updated timestamps, derived values, server-assigned IDs — flow back into the form via initialData.
const handleSubmit = formHandlers.handleSubmit(
async (submitState) => { /* ... */ },
{
resetDirty: true,
onSuccess: () => {
// Refetch fires in the background; dirty-field protection means the
// fresh data fills only fields the user hasn't touched since the save.
queryClient.invalidateQueries({ queryKey: ['profile', userId] });
},
}
);Surfacing server errors in the form
When the mutation throws, return an error object from the submit callback. Any key prefixed with _ is treated as a form-level error; other keys are matched against field paths.
// Form-level error — display it near the submit button or at the top
return { _serverError: 'The server is unavailable. Try again later.' };
// Field-level error returned from the server
return { email: 'This email is already registered.' };
// Read either with getManual() — schema errors use errors.name, errors.email etc.
<FormError error={formState.errors.getManual('_serverError')} />Manual errors set via a returned object are cleared automatically when the user next edits the affected field. formStatus.valid is false while any manual error is present; see the Manual Errors section for the full API.
DevTools
The form-state-tools package provides FormDock — a development-only floating panel that gives you a live view of your form's internal state, validation status, and captured runtime errors. It renders nothing in production (NODE_ENV=production).
npm install github:dstarosta/FormStateTools#v1.0.0Setup
Pass the entire return value of useFormState (or useFormStateContext) to the form prop. Drop it anywhere in your component tree — it positions itself as a fixed bottom dock.
import { FormDock } from 'form-state-tools';
function AppForm() {
const form = useFormState(schema); // or useFormStateContext(schema)
const { Form, formHandlers } = form;
return (
<>
<Form action={formHandlers.handleSubmit(onSubmit)}>
{/* ... */}
</Form>
<FormDock form={form} />
</>
);
}For Vite projects — especially Remix with SSR — NODE_ENV may not be set to development automatically. Without it, FormDock may render on the server and break SSR. Pass devMode={import.meta.env.DEV} to be explicit:
<FormDock form={form} devMode={import.meta.env.DEV} />What it looks like
The dock sits at the bottom of the viewport as a collapsible bar. The header shows a validation indicator — orange when unvalidated, red when invalid, hidden when valid. Clicking the header toggles between three sizes.
Clicking the header expands the panel to about 30% of the screen height and shows a live JSON tree. This example shows a valid form with the full structure visible:
When validation errors are present, error keys are highlighted and the header icon turns red:
Error toast
When a console.error() call or an unhandled exception is captured, a toast appears in the top-right corner showing the most recent error:
MOST RECENT CONSOLE ERROR
ConsoleError: Warning: Each child in a list should have a unique "key" prop. at NoteInputs (AppForm.tsx:84) at fieldset at Form
Props
| Prop | Type | Default | Description |
|---|---|---|---|
form | FormStateResponse | — | The object returned by useFormState or useFormStateContext. Required. |
devMode | boolean | auto | Override render behavior. Unset = auto-detect from NODE_ENV. |
collapsed | boolean | true | Initial size state. Overridden by sessionStorage if a previous size was saved. |
captureErrors | 'all' | 'thrown' | 'console' | 'none' | 'all' | Which errors to capture and display in the toast. |
ignoreErrorPatterns | (RegExp | string)[] | [] | Patterns to suppress — matched against the error message string. |
Panel interactions
| Gesture / Key | Effect |
|---|---|
| Click header | Toggle between minimized and normal (30% of screen height) |
| Right-click header | Toggle between normal and maximized (full screen height) |
| Space | Toggle minimized ↔ normal |
| Enter | Toggle normal ↔ maximized |
| Escape (toast) | Dismiss the error toast |
Configuration examples
// Start expanded, only capture console.error calls
<FormDock form={form} collapsed={false} captureErrors="console" />
// Suppress noisy third-party errors
<FormDock
form={form}
ignoreErrorPatterns={[
/ResizeObserver loop/,
'Non-Error promise rejection',
]}
/>
// Force-disable in dev for a specific story/test
<FormDock form={form} devMode={false} />FormDock saves the last-used panel size to sessionStorage so it persists across hot reloads. The collapsed prop is only used when there is no saved size.
When NODE_ENV=production, FormDock returns null immediately. No panel markup, no error listeners, no bundle overhead beyond the conditional check.
Comparison
How FormState compares to other popular React form libraries across commonly evaluated features. Analysis by Claude.
| Feature | FormState | TanStack Form | React Hook Form | Formik | Final Form |
|---|---|---|---|---|---|
| Bundle size (gzipped) | ~15 KB 1 | ~13 KB | ~10 KB | ~15 KB | ~9 KB |
| First-class TypeScript | ✅ | ✅ | ✅ | ❓ | ✅ |
| Fully inferred TypeScript | ✅ | ✅ | ✅ | ❓ | 🛑 |
| Typed lambda path expressions | ✅ | 🛑 | 🛑 | 🛑 | 🛑 |
| No field wrappers required | ✅ | 🛑 | 🛑 2 | 🛑 | 🛑 |
| No render props or abstractions | ✅ | 🛑 | 🛑 | 🛑 | 🛑 |
| Granular field-level re-renders | ❓ 3 | ✅ | ✅ | 🛑 | ✅ |
| Change and submit event subscriptions | ✅ | ✅ | ❓ | 🛑 | ✅ |
| React 19 actions / server actions | ✅ | ✅ | 🛑 | 🛑 | 🛑 |
| React 19 built-in form compatibility | ✅ | 🛑 | 🛑 | 🛑 | 🛑 |
| FormData support | ✅ | 🛑 | 🛑 | 🛑 | 🛑 |
| React Compiler support | ✅ | ✅ | 🛑 | ❓ | ❓ |
| SSR integrations | ✅ | ✅ | 🛑 | 🛑 | 🛑 |
| Built-in async validation debounce | ✅ | ✅ | 🛑 | 🛑 | 🛑 |
| Schema-based validation | ✅ | ✅ | ✅ | ✅ | 🛑 |
| Schema-derived field constraints | ✅ | 🛑 | 🛑 | 🛑 | 🛑 |
| Cross-field validation | ✅ | ✅ | ✅ | ✅ | ✅ |
| Array fields | ✅ | ✅ | ✅ | ✅ | ✅ |
| Initial data reactivity | ✅ 4 | 🛑 | 🛑 | 🛑 | 🛑 |
| Separate form and API type representations | ✅ | 🛑 | 🛑 | 🛑 | 🛑 |
| Built-in readonly / disabled mode | ✅ | 🛑 | 🛑 | 🛑 | 🛑 |
| Password value not stored in DOM | ✅ 5 | 🛑 | 🛑 | 🛑 | 🛑 |
| Unsaved changes navigation prompt | ✅ 6 | 🛑 | 🛑 | 🛑 | 🛑 |
| ESLint plugin | ✅ | ❓ 7 | ❓ 8 | 🛑 | 🛑 |
| First-party DevTools | ✅ | ✅ | ✅ | ❓ | 🛑 |
- FormState requires Zod Mini (~8-14 KB gzipped) and fast-equals (~2 KB gzipped). Other libraries are often coupled with schema libraries: TanStack Form and React Hook Form support Zod, Yup, Valibot, and others via adapters; Formik pairs primarily with Yup; Final Form has no built-in schema adapter but is more limited in validation capabilities.
- RHF's
registerAPI avoids wrappers for uncontrolled inputs, but controlled inputs require<Controller>. Theregisterabstraction spreads hidden props onto inputs, which can be unintuitive to reason about and debug.
- FormState re-renders the component that called
useFormStateon every state change.useWatchisolates keystroke re-renders to subscribers by observing the DOM element's value directly, independent of React state. For high-frequency inputs where even that is undesirable, use uncontrolled inputs instead.
- Non-dirty fields automatically reflect changes to
initialData(e.g. when server data loads asynchronously). Fields the user has already edited are never overwritten. This does not affect the dirty state of any field.
- Requires the
SecureInputcomponent instead of a plain<input type="password">. The value is kept out of the DOM and never appears inFormDataor browser autofill inspection.This feature keeps passwords safe from browser extensions that scan the DOM, prevents accidental copy/cut/paste/undo/redo of sensitive data.
- Opt-in via
confirmDirtyStateNavigation: true. Prompts the user before navigating away when the form has unsaved changes.
- TanStack Query ships @tanstack/eslint-plugin-query. TanStack Form does not have a dedicated ESLint plugin.
- React Hook Form has a community-maintained ESLint plugin. It is not officially supported by the React Hook Form team.
Ref-Based Form Library Issues
| Bug | FormState | TanStack Form | React Hook Form |
|---|---|---|---|
| Field value lost when component unmounts | ✅ state is schema-derived, not mount-dependent | 🛑 field unregisters on unmount by default | 🛑 configurable, but opt-out adds complexity |
| Field missing from submit if it mounts late | ✅ all fields present in state from initialization | 🛑 fields self-register; a late mount misses the submission | ✅ uncontrolled inputs registered eagerly via register() |
key prop change breaks field state | ✅ no registration to reset | 🛑 remount triggers re-registration and clears state | 🛑 remount drops the ref and unregisters the field |
| Array field identity desyncs from state | ✅ immutable operations, no generated IDs — use a Symbol field if item identity is needed | ❓ safe if using built-in methods; direct mutation desyncs | 🛑 useFieldArray tracks by generated ID; outside mutation desyncs |
ref conflicts on custom inputs | ✅ no ref used for tracking | ✅ form.Field does not rely on DOM refs | 🛑 register() owns the ref; manual merge required |