v1
Getting Started

FormState Guide

Schema-derived form state for React 19. No registration, no wrappers, no magic — the data lives in the schema, and your inputs are just inputs.

FormState pairs with Zod Mini to make a single schema the source of truth for field types, validation, constraints, metadata, and identity. State is a plain object you read directly. Updates produce new references that flow through React's normal render cycle, so useEffect, useMemo, and the React Compiler work the way the syntax suggests. There is nothing to register and no wrapper to render through.

Key traits:

  • Schema-first — one Zod schema drives types, validation, constraints, and metadata. Form data is the schema instantiated, not a separate registry.
  • Path expressions — address nested and array fields with typed lambda paths: (path) => path.info.address. Refactor-safe, IDE-navigable, no string keys.
  • End-to-end type safety — generics flow from schema to every method signature, callback, and state accessor.
  • Pure React, no magic — you call change, you read formState.data, React re-renders normally. No register() spreading hidden props, no ref-mediated value tracking, no render props.
  • React 19 ready — works with server actions, useFormStatus, useOptimistic, Suspense, and the new action prop on <form>.
  • Official ESLint plugin — schema-derived state has a narrow, well-defined misuse surface, which makes static linting unusually effective. The plugin catches footguns the runtime can't — like passing a useWatch return value into a hook dependency array — at development time.
  • Zero-config defaults — validation on change, errors after touch, submit flow all work out of the box.
Getting Started

Installation

Requirements

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.

bash
npm install react react-dom zod
npm install github:dstarosta/FormState#v1.0.0

Then import from the package root:

typescript
import { useFormState, z } from 'form-state';
Important

z should be imported from 'form-state', not 'zod' or 'zod/mini'.

Getting Started

ESLint

FormState ships an official ESLint plugin that catches common mistakes at development time — incorrect schema primitives, useWatch return values as hook dependencies, unsafe password inputs, unstable debounced callbacks, and groups placed on nested schema properties.

The plugin requires ESLint 9.0 or greater, which introduced flat config as the default format.

The only available config is recommended. It enables all the rules.

Configuration (flat config only)

javascript
// 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:

javascript
rules: {
  'form-state/avoid-input-password': 'error',
}

Rules

RuleDescriptionDefaultFixable
avoid-input-passwordEnforces 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-inline-schemaDisallows constructing a schema inline as the first argument to useFormState() or useFormStateContext(). An inline schema is rebuilt on every render and invalidates the hook's subscriptions — hoist the schema to module scope or memoize it.error
no-nested-groupDisallows group() on nested schema properties. Groups are only allowed on root-level properties, so a group placed on an object field, an array element, or any deeper field is reported instead of failing at runtime.error
no-watch-dependencyPrevents 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
prefer-validate-over-refineRecommends z.validate() over z.refine() when the refine call only uses features validate supports, keeping whole-object validation on a single stage. An async refine predicate gets a separate, non-fixable advisory toward z.validateAsync(). Skips calls that need raw-refine features (the when payload, custom issue codes, abort).warn✅ (sync only)
stable-debounced-callbackEnforces 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
use-form-schemaEnforces 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✅
Getting Started

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.

Built on patterns React developers already know

FormState doesn't invent its own vocabulary. Its building blocks map to patterns already in a working React developer's muscle memory — React 19 form actions used directly, Reselect-style memoized selectors, Immer-style immutable updates via updateState, clsx-style class composition through classNames and formClasses, and a Redux-style typed store mutated by actions and observed by listeners. Each pattern is documented in its own section below.

The deliberate constraint is to keep magic to a minimum. No register() spreading hidden props onto inputs, no ref-mediated value tracking, no implicit re-render orchestration, and — most importantly — no render prop wrapping every field. Inputs are plain HTML elements; the form's state is a plain object you read directly. When something works, it works because the rules are the same as the rest of React — not because the library quietly intervened on your behalf.

Depth over portability

Form libraries that target every UI framework and every schema library can only ship what those targets have in common. Framework-agnostic cores can't lean on React 19's form actions, useFormStatus, useOptimistic, Suspense for pending submissions, or Server Actions — those are host-specific. Schema-agnostic cores can't surface any one library's full validation surface either, because the adapter layer between them collapses everything to a common interface.

FormState makes the opposite trade: a hard commitment to React 19+ and Zod v4 Mini, used at full depth. React 19 features are used directly, not paraphrased through an adapter. Zod's check chains, refinements, z.regexes, and inferred types flow through to the form layer without translation. The cost is real lock-in — swapping out React or the schema layer isn't a one-line adapter change, it's a refactor with bug risk. The trade is deliberate: FormState targets modern React developers who have already chosen React 19 and Zod 4, and rewards that choice with deeper integration than any portable library can offer.

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, React Router 7 and TanStack Start.
  • Server Actions drop in seamlessly with no adapters or wrappers required. See Next.js, React Router 7 and TanStack Start.
  • FormData is natively supported along with JSON data. See Form Submission.
  • Suspense support allows you to declaratively handle loading states for pending submissions. See TanStack Query.
  • ErrorBoundary works out of the box to gracefully catch and display submission errors. See TanStack Query.
  • useFormStatus works in any child component without extra setup or context providers.
  • useOptimistic integrates naturally for instant UI feedback during submissions.
  • startTransition is fully supported — render form components in the background without blocking the main thread.1
  • Predictable render cycle. Form state flows through React's normal render path — including for uncontrolled inputs — so every value is reactive and reference-stable. The one deliberate exception is useWatch, which bypasses React for high-frequency reads.2
  • Immutable form state supports built-in memoized selectors and standard React hooks like useEffect/useLayoutEffect, useMemo and useCallback.
  1. Libraries that are using useSyncExternalStore for 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

  1. Because useWatch bypasses React's render cycle, its return value can change between renders without triggering React's normal dependency tracking. That's what makes it fast for high-frequency reads — but it also means the value must only be used inside the render function, never as a useEffect, useMemo, or useCallback dependency. The no-watch-dependency ESLint rule catches this statically.

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.

React is controlled

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.

Edge cases:

  1. 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:

    tsx
    <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.

  2. Resetting form state — clearing every field back to its initial values when the user switches to a new record — follows the same React idiom: change the key prop on the component that owns useFormState. This is Dan Abramov's reset state with a key pattern — the parent remounts, every hook inside it resets, and useFormState starts fresh from initialData.

    tsx
    function EditPage({ id, data }) {
      // Key the component that owns useFormState so the hook unmounts and starts fresh
      return <EntityForm key={id} data={data} />;
    }
    
    function EntityForm({ data }) {
      const { Form } = useFormState(schema, { initialData: data });
      return <Form>...</Form>;
    }

    The hook has to live inside the keyed component. If useFormState sat in EditPage instead, state would persist across id changes because React keeps the parent's hook alive.

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.1

  1. Both libraries have open reactivity and reference-stability issues that surface in ordinary React patterns and especially under the React Compiler — direct consequences of holding form state outside React's render cycle.

    TanStack Form (external store):
    #1030 — Support for React Compiler
    #1379 — Error display example is NOT React Compiler friendly
    #1501 — useFieldContext crashes when React Compiler is enabled
    #2090 — form.Subscribe causes unnecessary re-renders when selector returns objects or arrays
    #1108 — Every <form.Field> rerenders when any field changes

    React Hook Form (mutable internal state):
    Discussion #12524 — React Compiler compatibility. watch(), getValues(), and reset() rely on mutable internal state and return stale values inside memoized children; the recommended workaround is migrating to useWatch() / useController(), or disabling the Compiler with 'use no memo'.

State helpers

Three standalone helpers cover the cases where you need to read, create, or update form data without going through the formActions API on a live form instance.

The getState method reads form data outside the form's render tree — in components or utility modules that have only a plain data object and the underlying Zod schema, not the live formState.

tsx
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.

tsx
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 assign them back using the change method. The syntax is very similar to the produce method in the Immer library.

tsx
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);

The createSymbol method returns a fresh Symbol.for(uniqueId). Use it when manually constructing items for an array whose element schema declares a z.symbol() identity field — for example, seeding initial array data outside of createState or duplicating an existing item with a new identity.

tsx
import { createSymbol } from 'form-state';

// Duplicate an existing note with a fresh identity
const duplicated = {
  ...formState.data.notes[0],
  uniqueId: createSymbol(),
};

formActions.array.append((path) => path.notes, duplicated);

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.

tsx TanStack Form
// 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>
tsx React Hook Form
// 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>
  )}
/>
tsx Formik
// 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>
tsx FormState
// 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.

Trade-off

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.

Getting Started

TypeScript

FormState is built around a handful of exported generic state 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:

TypeScript version

Types require TypeScript 5.9 or greater. Earlier versions will produce incorrect or missing type errors.

Strict mode

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.

json
// 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.

Tip

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.

This is the same approach the React docs recommend for state shape — see Avoid deeply nested state.

Common Types

These helper types are exported from form-state for typing wrapper components, callbacks, and reusable helpers. The generic parameter T is your Zod schema type — the same one you pass to useFormState(schema, ...). Provide it explicitly when inference does not apply (for example, in the props of a generic component) so paths, errors, and state values stay typed end-to-end.

FormSchema in these examples

Throughout the examples below, FormSchema refers to your inferred schema type — type FormSchema = z.infer<typeof schema> — where schema is the object you pass to useFormState. Define it once and reuse it wherever a form type is needed.

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.

typescript
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.

Warning

The callback option requires a stable reference — define it outside the component or wrap it in useCallback.

typescript
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.

typescript
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 error

SubmitState<T>

The discriminated union passed to your handleSubmit callback. Narrow on valid to get a typed data object.

typescript
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.

typescript
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 — symbol fields stripped, empty strings removed, Date values serialized to strings via each field's configured format.

typescript
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.

typescript
import { type StateChangeListener } from 'form-state';

// Defined outside the component
const listener: StateChangeListener<FormSchema> = ({ type, data, submitCount }) => {
  if (type === 'submit') {
    analytics.track('form_submit', { count: submitCount, name: data.name });
  }
};

// In the component
formHooks.useListener(listener);

Group<T, G>

The type returned by formState.getGroup(name) for the group G of schema T. Use it to type a component prop that receives a single group's slice of the form state — the bundle is narrowed to exactly the fields assigned to that group. The group name is checked against the schema, so an unknown name is a compile error.

typescript
import { type Group } from 'form-state';

// A section component that receives one group's bundle as a prop
function ContactSection({ contact }: { contact: Group<FormSchema, 'contact'> }) {
  return (
    <fieldset>
      <legend>Contact</legend>
      <input value={contact.data.email} readOnly />
      <input value={contact.data.phone} readOnly />
      {/* contact.data.age — compile error: 'age' is not in the 'contact' group */}
    </fieldset>
  );
}

// Caller — getGroup() returns a value assignable to the Group prop
<ContactSection contact={formState.getGroup('contact')} />

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.

typescript
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,
});

Options Types

Every form action accepts an options object whose type is exported from form-state. Import these when typing wrapper components or helpers that forward action options. Types parameterized by T take your Zod schema type.

ElementFocusOptions<T>

Options for formActions.focus.

FieldDefaultDescription
errorKey—When provided, focuses only if there is an active error at the given field path or manual error key. Accepts a path expression or a string key.
focusVisibletrueShows that the element is focused using an outline style, if defined.
preventScrollfalseDo not scroll the focused element into view.
selectTextfalseSelects the text content of the focused input.

FormChangeArrayOptions<T>

Options for the formActions.array.* methods (append, insert, remove, move, swap, replace). Same shape as FormChangeOptions minus debounceIntervalMs.

FieldDefaultDescription
callback—Optional callback to run after the form state has been changed. Receives the updated FormState and FormStatus.
touchfalseMark affected fields as touched.
validatetrueValidate the form after the array change. Overridable via the validateOnChange hook option.

FormChangeOptions<T>

Options for formActions.change.

FieldDefaultDescription
callback—Optional callback to run after the form state has been changed. Receives the updated FormState and FormStatus. Must be stable — define it at module scope or wrap it in useCallback.
debounceIntervalMs0Debounce interval in milliseconds before dispatching the change. Intended for text inputs paired with defaultValue — see Debounced Changes.
touchfalseMark the field as touched.
validatetrueValidate the field after the change. Overridable via the validateOnChange hook option.

FormClassOptions

Options for formClasses(nameOrPath, options). Combine a custom CSS prefix with a classNames string or FormClassCallback. See CSS Classes for usage patterns.

FieldDefaultDescription
prefix'form-state'Overrides the CSS prefix for the generated state classes ([prefix]__error, [prefix]__touched, [prefix]__required, [prefix]__disabled, [prefix]__readonly) for this call only. Set globally via the cssOptions.prefix option of useFormState. Set to null to disable prefix classes entirely.
classNames—Either a static string appended verbatim, or a FormClassCallback that receives { isError, isTouched, isRequired, mode } and returns a clsx-compatible value (string, object, array, or falsy).
Prefix classes

Prefix classes are emitted automatically based on the field's state and form mode — [prefix]__required, [prefix]__disabled, [prefix]__readonly, [prefix]__touched, and [prefix]__error. They sit at the front of the class string and are independent of classNames. Disable them with prefix: null. See CSS Classes for the full conditions.

FormClassState

The argument passed to a FormClassCallback.

FieldTypeDescription
isErrorbooleantrue when the field has a validation error and the form has been validated.
isTouchedbooleantrue when the field has been touched.
isRequiredbooleantrue when the field is required by the schema.
modeFormModeCurrent form mode — 'editable', 'readOnly', or 'disabled'.

FormClassCallback

A callback that produces CSS classes from the current field state. Accepted by formClasses(field, ...) and by cssOptions.classNames. Receives a FormClassState ({ isError, isTouched, isRequired, mode }) and returns a clsx-compatible value. Import this type when defining the callback outside a component — e.g. to share styling across forms or to keep a stable reference across renders.

typescript
type FormClassCallback = (state: FormClassState) => FormClassValue;

type FormClassValue =
  | string
  | Record<string, boolean | null | undefined>
  | readonly FormClassValue[]
  | false
  | null
  | undefined;
typescript
import { type FormClassCallback } from 'form-state';

// Shared field styling — defined once, reused across forms.
const fieldClasses: FormClassCallback = ({ isError, isTouched, mode }) => [
  'block w-full rounded border px-3 py-2',
  mode === 'disabled' && 'bg-slate-100 text-slate-400 cursor-not-allowed',
  isError && isTouched && 'border-red-400 bg-red-50/50',
];

// Apply per call.
<input className={formClasses('name', fieldClasses)} />

// Or as the form-level default.
useFormState(schema, { cssOptions: { classNames: fieldClasses } });

FormInitOptions<T>

The second argument to useFormState(schema, options) — controls initial data, validation timing, and miscellaneous form-wide behavior.

FieldDefaultDescription
cssOptions{ prefix: 'form-state' }Form-level defaults for formClasses — accepts the FormClassOptions fields (prefix and classNames). A per-call formClasses(field, ...) argument fully replaces the form-level value for the same key. Set cssOptions.prefix to null to opt out of the generated prefix classes (see CSS Classes).
debounceCacheCapacity50Capacity of the debounce callback cache used by change. A non-positive value disables debouncing. Smaller values save memory but can drop pending callbacks under heavy concurrent use.
errorMessageSeparator'|'Separator used when multiple errors are joined into one message for the same state property.
inferredNameFormat'bracket'Default format used by inferName — 'bracket' produces info[address][city], 'dot' produces info.address.city.
initialData—DeepPartial object with property values applied on mount. Non-dirty fields stay in sync with reactive updates to this reference.
initialMode'editable'Initial FormMode — one of 'editable', 'readOnly', or 'disabled'.
initialTouched—An array of field names or path expressions to mark as touched at mount — useful when editing existing data so errors show without the user visiting each field.
resetTouchedOnFormResetfalseReset the touched status when the form element fires reset. Only applies to a <Form> without a custom onReset.
validateBeforeSubmittrueValidate on change, touch, replace, and setError/clearManualErrors before the form has been explicitly validated. Skipped once validated is set by validateOnMount or a manual validate call.
validateOnChangetrueDefault value for change's validate option.
validateOnMountfalseRun validation immediately when the hook mounts.
validateOnTouchtrueDefault value for touch's validate option.
watchfalseEnable the useWatch hook. Adds some memory overhead — leave off unless you need it.

FormProviderInitOptions<T>

The options object passed to FormStateProvider. Extends FormInitOptions<T> with one additional required field.

FieldDefaultDescription
schema—The Zod Mini schema for the form — same value you would pass as the first argument to useFormState.

FormResetOptions<T>

Options for formActions.reset.

FieldDefaultDescription
callback—Optional callback to run after the form state has been reset.
data—Resets the form to this data instead of its initialData. You can pass a partial object — any missing fields fall back to their schema defaults. With names, only those fields are reset, taking their values from here when present and from initialData otherwise. This never changes the form's initialData, so a later plain reset still goes back to the originals. Has no effect when retainData is true.
names—Optional array of root-level field names to reset. If not provided, all fields are reset.
resetTouchedfalseReset the touched state of the fields.
retainDatafalseRetain the current field values instead of restoring initials.

FormSubmitOptions<T>

Options for formHooks.useSubmit / the submit pipeline — passed alongside an onSubmit callback.

FieldDefaultDescription
onError—Callback invoked when submission fails — either due to validation errors or onSubmit returning false. Receives the updated FormState and FormStatus.
onSuccess—Callback invoked on successful submission. Receives a SubmitSuccessState — typed data plus the raw FormData.
resetDirtytrueReset the dirty state of the fields after a successful submit.
resetTouchedtrueReset the touched state of the fields after a successful submit.
updateInitialDatatrueUpdate the initial form data with the submitted data so a subsequent reset restores the submitted snapshot.

FormTouchOptions

Options for formActions.touch.

FieldDefaultDescription
validatetrueValidate the field after marking it as touched. Overridable via the validateOnTouch hook option.

FormValidateOptions<T>

Options for formActions.validate.

FieldDefaultDescription
callback—Optional callback to run after the form state has been validated.
resetDirtytrueReset the dirty state of the fields.
resetTouchedtrueReset the touched state after submission. Only applies when submit is true; regular validations leave touched flags alone.
submitfalseMark the form as submitted when the state is valid. Useful for treating validate({ submit: true }) as a programmatic submission.
updateInitialDatatrueUpdate the initial form data with the validated data so a subsequent reset restores it.
Getting Started

Comparison

How FormState compares to other popular React form libraries across commonly evaluated features. Analysis by Claude.

Every form library is a compromise. What separates them is how openly they disclose those compromises. FormState's costs are real and worth naming up front: a larger bundle than the smallest alternatives, a hard dependency on Zod Mini, and an opinionated model of where form state lives. The rows below try to be honest about those trade-offs — FormState's included — rather than reading as a scoreboard.

FeatureFormStateTanStack FormReact Hook FormFormikFinal Form
First-class TypeScript✅✅✅❓✅
Fully inferred TypeScript✅✅✅❓🛑
Typed lambda path expressions✅🛑🛑🛑🛑
No field wrappers required✅🛑🛑 1🛑🛑
No render props or abstractions✅🛑🛑🛑🛑
Granular field-level re-renders❓ 2✅✅🛑✅
Change and submit event subscriptions✅✅❓🛑✅
React 19 form actions✅❓ 3🛑🛑🛑
FormData support✅🛑🛑🛑🛑
React Compiler support✅✅🛑❓❓
SSR integrations✅✅🛑🛑🛑
Built-in async validation debounce✅✅🛑🛑🛑
Schema-based validation✅✅✅✅🛑
Schema-derived field constraints✅🛑🛑🛑🛑
Export schema as JSON Schema✅ 4🛑🛑🛑🛑
Cross-field validation✅✅✅✅✅
Server validation integration✅ 5❓❓❓❓
Array fields✅✅✅✅✅
Initial data reactivity✅ 6🛑🛑🛑🛑
Separate form and API type representations✅🛑🛑🛑🛑
Built-in readonly / disabled mode✅🛑🛑🛑🛑
Built-in input masking✅🛑🛑🛑🛑
Password value not stored in DOM✅ 7🛑🛑🛑🛑
Unsaved changes navigation prompt✅ 8🛑 8🛑🛑🛑
ESLint plugin✅❓ 9❓ 10🛑🛑
First-party DevTools✅✅✅❓🛑
  1. RHF's register API avoids wrappers for uncontrolled inputs, but controlled inputs require <Controller>. The register abstraction spreads hidden props onto inputs, which can be unintuitive to reason about and debug.
  1. FormState re-renders the component that called useFormState on every state change. Isolate keystroke re-renders with useWatch, or use uncontrolled inputs for fields where even that is undesirable.
  1. TanStack Form supports server actions invoked from form.handleSubmit, but submission still routes through an onSubmit handler — it does not natively integrate with React 19's <form action={fn}> prop.
  1. schema.toJSONSchema() exports a standard JSON Schema describing the clean data shape, including required fields, value constraints, allowed values, and date formats (see Form State). Custom function validations — anything expressed as a refinement or an async check — cannot be represented in JSON Schema and are not reflected in the output. Other libraries rely on an external validation schema (such as Zod or Yup) and do not offer a built-in export.
  1. Server-returned errors (e.g. a 422 with { email: 'Already taken' }) are set as manual errors. They persist across re-renders, clear when the user edits the field, and behave like schema errors — including how they affect touched and formStatus.valid. You set them under any key you choose — a field name, or something like serverError for a form-level message (see Form Submission). Other libraries support manual error setting but the persistence semantics across submits, edits, and resets vary — verify against your expected UX.
  1. 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.
  1. Requires the SecureInput component instead of a plain <input type="password">. The value is kept out of the DOM and never appears in FormData or 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.

  1. Provided by the useBlocker hook (formHooks.useBlocker(shouldBlock)). Guards both tab close / reload (via beforeunload) and in-app navigation routed through its guard resolver, blocking while the form is dirty (or any condition you return). This mirrors TanStack Router's useBlocker, but is router-agnostic — shouldBlock receives the form state and status instead of the target route.
  1. TanStack Query ships @tanstack/eslint-plugin-query. TanStack Form does not have a dedicated ESLint plugin.
  1. React Hook Form has a community-maintained ESLint plugin. It is not officially supported by the React Hook Form team.

Schema-derived state, in plain terms

Most form libraries — TanStack Form, React Hook Form, Formik — let each field component own its value. When a <Field> mounts, it registers with the form. When it unmounts, it unregisters. The form's data at any given moment is whatever the currently-mounted fields say it is. Whether that registry lives in DOM refs (React Hook Form) or an external store (TanStack Form) is a detail — in both cases the data is a side-effect of which components are currently rendered.

FormState works the opposite way. The schema declares the form's shape up front, so the data exists before any field component renders. Fields are plain controlled inputs reading from and writing to a shared state object. Nothing registers, nothing unregisters. That single design choice rules out an entire class of mount-lifecycle bugs — these aren't bugs FormState has patched, they're bug classes the architecture rules out.

Component-Owned State: Concrete Bugs

BugFormStateTanStack FormReact 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()
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

The same design choice has consequences that extend beyond the bug catalog:

  • State follows React's rules. data.firstName in a useEffect dependency works the way the syntax suggests. TanStack Form keeps state in TanStack Store, and Final Form in its own external store — reads like form.state.values.x look identical to React state but don't trigger re-renders or invalidate dependency arrays. Silent footgun, especially for developers still building their React mental model.

  • Validation is deterministic. TanStack Form's per-field validators register at mount and fire on their own event slots (onChange, onBlur, onSubmit) — which is why an empty submit can light up every error at once, and individual errors can flicker as you type depending on which slot ran last. FormState runs the whole schema once over the canonical state on every change, so the error list is a pure function of the data — same input, same errors, every time.

  • Footguns are caught at lint time. FormState ships an official ESLint plugin that flags misuse patterns the runtime can't catch — for example, passing a useWatch return value into a hook dependency array. No other form library ships an official equivalent: React Hook Form has only a community-maintained plugin; TanStack Form has none. The asymmetry isn't an accident — static linting works best when the library has a narrow, well-defined misuse surface, which schema-derived state happens to provide.

Form data as a state slice

Most form libraries treat the form as a self-contained island: data goes in via defaultValues, comes out via handleSubmit, and is opaque to the rest of the app in between. Reading the current value of a field elsewhere in the tree — for a sidebar preview, a dependent query, or to persist to local storage — forces lifting state up, drilling props, or reaching in with refs.

A consequence of component-owned state: the subscription APIs other libraries do expose (useWatch, form.subscribe) only fire inside React's render cycle. Wiring a non-React consumer — a Redux middleware, a service worker, a BroadcastChannel across tabs — means writing your own bridge and keeping it in sync as fields mount and unmount. FormState's state is a real store with a stable, schema-defined shape and a typed event stream, which is exactly the contract every "subscribe to changes" integration already expects.

FormState treats form data the way Redux treats application state: a typed, observable slice with a defined shape, accessed anywhere via context, mutated by actions, and subscribable by listeners. The schema is the slice's contract.

  • Context-based access to data, not just the API. Other libraries' context (useFormContext, formContext) exposes the form instance; you still subscribe per-field with watch or <Controller> to get reactive values. With FormState, wrap a tree with formConnect and any descendant calls useFormStateContext to read the form's data, status, and actions directly as a typed, subscribed slice — selector-style, so reads are reactive without forcing tree-wide re-renders.

  • Listeners sync to other stores. formHooks.useListener is FormState's equivalent of store.subscribe: mirror state into a Redux slice, push it into TanStack Query, write to IndexedDB, broadcast across tabs — anything with a "set" API can stay in lockstep with the form.

  • Lifecycle as a single tagged stream. Listeners receive a tagged event — 'change', 'submit', and so on — so the form's lifecycle becomes a substrate for features beyond validation: multi-step wizards that advance on each step's submit, undo/redo by snapshotting state on every change, draft persistence to sessionStorage that clears on successful submit. Other libraries route change and submit through separate APIs; FormState routes both through one ordered stream, so handling them stays in one place.

  • Replace-style updates for async data. formActions.replace(data) swaps in new data while preserving schema invariants — dirty tracking, validation, and derived state continue to work correctly across the swap.

  • Survives lazy mounts and Suspense. Because state is schema-derived, a lazy()-loaded or <Suspense>-wrapped field group can arrive at any time — the data was there before the component, and stays there if it unmounts.

Form state as data slice — examples:

// Mirror every valid form change into a Redux slice
formHooks.useListener((state) => {
  if (state.type === 'change' && state.valid) {
    dispatch(updateFormState(formSchema.toObject(state.data)));
  }
});

// Hydrate the form from a fetch — form status and schema invariants preserved
useFetchData((data) => {
  formActions.replace(data);
});

// Draft persistence — restore on mount, save on change, clear on successful submit
const savedData = sessionStorage.getItem('draft');

const form = useFormState(schema, {
  initialData: savedData ? JSON.parse(savedData) : undefined,
});

formHooks.useListener((state) => {
  if (state.type === 'change') {
    sessionStorage.setItem('draft', JSON.stringify(state.data));
  } else if (state.type === 'submit') {
    sessionStorage.removeItem('draft');
  }
});

Other form libraries treat cross-store integration as application-developer plumbing. FormState treats it as a first-class surface — the form isn't an island, it's a participating member of your state ecosystem.

When FormState is the wrong choice

FormState's design assumptions don't suit every project. The fit is best when these are true; if one or more isn't, another library is likely the better tool.

  • You aren't already using Zod. The schema isn't an optional resolver here — it's the contract that drives data shape, derived constraints, typed paths, and the async-validation pipeline. Adding Zod purely to use FormState pays Zod's bundle cost without recouping it elsewhere; React Hook Form with a lightweight validator (or none at all) will be smaller and simpler.

    Yup, Valibot, Joi, ArkType, and hand-rolled validators don't plug in. React Hook Form and TanStack Form are schema-agnostic and will integrate with what you already use.

  • You need granular, per-field re-renders by default. FormState re-renders the whole form component on each change, but React only updates the DOM for the field you actually changed — so it stays fast and feels granular even though the re-render isn't. With uncontrolled inputs (the default) a keystroke doesn't re-render the component at all; even controlled value= inputs are fine for ordinary forms — the whole-form re-render measures in single-digit milliseconds. It only becomes worth avoiding on a very large controlled form, where you would reach for useWatch to narrow the render to the fields that changed; React Hook Form's register-by-default model gets you there with less thinking, though it does so by keeping values in refs outside React — sidestepping the render cycle rather than working with it.

  • You expect form state and application state to be the same thing. FormState owns form-editing state — masked strings, empty-vs-undefined, dirty/touched, validation status — using its own internal store. Like React Query owning server-cache state alongside Zustand, FormState coexists with Redux, MobX, Zustand, or any external store.

    One consequence: this store lives inside the hook, so the editing state lives and dies with the component. If the form unmounts — a route change, a suspending <Suspense> boundary, conditional rendering — the in-progress edits go with it. Usually that's fine; when it isn't (a wizard, a form that must survive navigation), mirror the state out so there's a copy to restore from.

    A one-line useListener mirrors every valid change into the external store, translating the editing shape into the canonical API/business shape as it goes. The form holds the editing shape, the external store holds the canonical shape, and the schema is the bridge between them. If instead you want a single store to hold both representations and drive the form directly — with no bridge — then FormState's schema-derived model isn't the right fit.

In short

None of the above is a flaw to apologize for — they're consequences of the bets the library makes. Forms that fit those bets get a meaningfully better developer experience than the generic libraries can provide. Forms that don't fit them are better served by picking a library whose bets line up with the constraints.

Getting Started

License

FormState is released under the MIT License.

text
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 Formik is licensed under Apache 2.0.


Core Concepts

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.

typescript
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.

typescript
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

ConstructorTypeNotes
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?)booleanCoerces 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()symbolAuto-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.
Warning

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.

Tip

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.

Core Concepts

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.

OptionTypeDefaultDescription
requiredbooleanfalseMakes an empty string invalid.
errorstring—Custom error message for required and type validation.
allowEmptybooleantrueWhether toObject() retains an empty string in the output. Set to false to strip blank fields from submitted data.
normalize'NFC' |
'NFD' |
'NFKC' |
'NFKD'
—Unicode normalization form to apply to the string before validation, equivalent to String.prototype.normalize(form). Omit to skip normalization.

Zod string checks — z.minLength, z.maxLength, z.regex, etc. — are passed as additional arguments after the options object.

typescript
// 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.

OptionTypeDefaultDescription
requiredbooleanfalseMakes an empty string invalid.
errorstring—Custom error message for required and type validation.
typescript
// 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.

OptionTypeDefaultDescription
requiredbooleanfalseMakes a missing or empty value invalid.
errorstring—Custom error message for required validation.
typescript
// 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. String input is parsed according to dateFormat; invalid strings produce a validation error rather than crashing. An empty string represents a blank date input.

Note

If you have a time component for the date, use a separate z.formString schema field. Most forms have individual controls for the date and the time components. If a combined field is required, use the z.formString field for the both components and do the formatting manually in event handlers.

Use z.regex to validate a specific date/time format. Zod provides z.regexes.date, z.regexes.datetime and z.regexes.time helpers to define regular expressions to match your format.

OptionTypeDefaultDescription
requiredbooleanfalseMakes an empty string invalid.
errorstring—Custom error message for required validation.
dateFormatstring'yyyy-MM-dd'Format string used to parse string inputs into Date objects. Also stored in schema metadata and readable via formState.
dateFormatErrorstring—Error message shown when a string cannot be parsed as a valid date.
typescript
// 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.

OptionTypeDefaultDescription
requiredbooleanfalseMakes an empty selection invalid. Narrows the output type to the listed values only.
errorstring—Error message shown when the value is not one of the allowed values.
typescript
// 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' })
Note

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).

OptionTypeDefaultDescription
requiredbooleantrueWhen false, wraps the array in z.optional() so the field may be absent.
minLengthnumber—Minimum number of items. Exposed via formState.ranges.
maxLengthnumber—Maximum number of items. Exposed via formState.ranges.
errorstring—Error message for array type validation.
lengthErrorstring—Error message for minLength / maxLength violations.
typescript
// 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.

ParameterTypeDefaultDescription
predicate(item, prevItem?, prevResult?) => boolean—Returns true when the value is valid. prevItem and prevResult hold the input and result from the previous call (both undefined on first call); return prevResult to skip recomputation when relevant fields haven't changed.
condition(errors: ZodValidationError[]) => booleanalways runsCalled with the current validation errors before running the predicate. Return false to skip this check entirely.
pathPropertyKey | PropertyKey[]—Key in formState.errors where the error is stored. Defaults to the root (no path).
errorstring—Custom error message.
typescript
// 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' }
    )
  );

// Skip a complex validation when no relevant fields were changed.
const schema = z
  .strictObject({
    id: z.formString(),
    firstName: z.formString(),
    lastName: z.formString(),
  })
  .check(
    z.validate(
      (data, prevData, prevResult) => {
        if (
          prevResult !== undefined &&
          data.firstName === prevData?.firstName &&
          data.lastName === prevData.lastName
        ) {
          return prevResult;
        }

        // an intentionally over-complicated validation here
        let count = 0;
        for (const char of data.lastName) {
          count++;
        }
        for (const char of data.lastName) {
          count++;
        }
        return count <= 30;
      },
      { path: 'lastName', error: 'Full name must be 30 characters or less.' }
    )
  );

z.validateAsync(predicate, params?)

Asynchronous counterpart to z.validate. The predicate returns a Promise<boolean>, making it suitable for checks that depend on network calls — uniqueness lookups, server-side eligibility, address verification, and similar. The parameter shape (condition, path, error) and shorthand string-error overload match z.validate exactly.

useFormState detects async checks in the schema and runs them automatically. While an async pass is in flight, formStatus.validating is true and formStatus.valid is null. Rapid keystrokes do not produce out-of-order errors — only the latest pass surfaces.

ParameterTypeDefaultDescription
predicate(item) => Promise<boolean>—Resolves to true when the value is valid. To skip re-running the predicate when the input hasn't changed, use the skipWhen option below.
condition(errors: ZodValidationError[]) => booleanalways runsCalled with the current validation errors before running the predicate. Return false to skip this check entirely.
skipWhen(item, prevItem) => booleandeep equalitySkip the network call when the value hasn't meaningfully changed since the last pass. By default the predicate is skipped when the inputs are deeply equal — override when the check is attached to a larger object but only depends on a few fields, e.g. (a, b) => a.sku === b?.sku && a.quantity === b?.quantity on an order schema where only sku and quantity affect the stock lookup.
submitOnlybooleanfalseRun the check only on submit (or when you call formActions.validateAsync()). Useful for expensive verifications like payment authorization that don't need to fire on every keystroke.
pathPropertyKey | PropertyKey[]—Key in formState.errors where the error is stored. Defaults to the root (no path).
errorstring—Custom error message.
debounceMsnumber0 (disabled)When greater than 0, the predicate is debounced by this many milliseconds so it runs only once per quiet window, after the last call.
typescript
// Field-level async check — verify the username is not already taken.
// The default `skipWhen` (deep equality) automatically suppresses the
// network call when the input hasn't changed since the last committed pass.
const schema = z.object({
  username: z
    .formString({ required: true, error: 'Username is required' })
    .check(
      z.validateAsync(async (name) => {
        const res = await fetch(`/api/usernames/${encodeURIComponent(name)}`);
        const { available } = await res.json();
        return available;
      }, 'Username is already taken')
    ),
});

// Object-level async check with a condition gate and a custom skipWhen.
// Only the `sku` and `quantity` fields matter for the stock lookup, so the
// custom comparator ignores any other root-level changes.
const orderSchema = z
  .strictObject({
    sku: z.formString({ required: true }),
    quantity: z.formNumber({ required: true }),
    notes: z.formString(),
  })
  .check(
    z.validateAsync(
      async (order) => {
        const res = await fetch(`/api/stock?sku=${order.sku}&qty=${order.quantity}`);
        return res.ok;
      },
      {
        // Skip the network call until both fields parse cleanly.
        condition: (errors) => errors.length === 0,
        skipWhen: (a, b) =>
          b !== undefined && a.sku === b.sku && a.quantity === b.quantity,
        path: 'quantity',
        error: 'Not enough stock for the selected quantity',
      }
    )
  );

// Debounced async check — collapses keystroke bursts to a single network call
const usernameSchema = z.object({
  username: z
    .formString({ required: true })
    .check(
      z.validateAsync(
        async (name) => {
          const res = await fetch(`/api/usernames/${encodeURIComponent(name)}`);
          const { available } = await res.json();
          return available;
        },
        {
          path: 'username',
          error: 'Username is already taken',
          // Wait 400ms of inactivity before hitting the server.
          debounceMs: 400,
        }
      )
    ),
});

// Submit-only async check — the payment-method authorization is expensive
// and only meaningful right before submission. It is skipped on every
// change-driven validation and runs once when `formActions.validateAsync()`
// (or the submit flow) fires.
const paymentSchema = z.object({
  cardToken: z
    .formString({ required: true })
    .check(
      z.validateAsync(
        async (token) => {
          const res = await fetch(`/api/payments/authorize?token=${token}`);
          return res.ok;
        },
        {
          error: 'Card could not be authorized',
          submitOnly: true,
        }
      )
    ),
});
Tip

Use formStatus.validating to render a spinner next to the submit button or disable it while async checks are pending. For per-field indicators, subscribe to the async listener events (asyncValidating / asyncValidated) — they carry the triggerField path and are coalesced across overlapping passes so indicators don't flicker.

Predicate rejection

If the predicate's Promise rejects (network failure, bug in the validator), the rejection is surfaced as a root-level error (formState.errors['']) and formStatus.validating flips back to false so the form does not freeze. The error message is the Error.message of the rejection, falling back to 'Async validation failed.'.

Known caveat: on initial mount, useFormState probes the schema once via a synchronous parse to decide whether it needs the async pipeline. That probe invokes the predicate and abandons the returned Promise as soon as Zod detects it is async — so a predicate that rejects (rather than resolves false) will produce one unhandledRejection warning in development. The error is still surfaced correctly via the async pass that runs immediately after. Prefer resolving true / false over throwing in predicates; reserve rejection for unrecoverable failures.

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.

typescript
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.

typescript
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.

ParameterTypeDefaultDescription
deepEqualitybooleanfalseUse 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.
errorstring—Custom error message placed on the duplicate element.
elementPathPropertyKey[]—Path within each array element where the error is attached. E.g. ['email'] makes the error appear under items[1].email.
ignoreValuesunknown[]—Values skipped when checking for duplicates — typically [''] to ignore blank entries.
typescript
// 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.

Warning

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.

Core Concepts

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>.

tsx
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>
  );
}
Core Concepts

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.

typescript String — top-level only
// 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);
typescript Arrow function — any depth
// 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.

typescript
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

SituationUse
Top-level field in a simple formEither — 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 runtimeString (arrow function can't be dynamic)
Core Concepts

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.

typescript
formState.data.name              // string
formState.data.age               // number | ''
formState.data.info.address.city // string (nested)
formState.data.tags              // string[]
formState.data.tags[0]           // string

errors

Validation error messages, one per failing field.

typescript
// 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:

typescript
// 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.

tsx
// 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

tsx
// 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(), or z.formDate()) as raw strings for input[pattern]
formState.patterns.name                             // '^[\\w\\s\'-]+$'
formState.patterns.get((path) => path.info.id)      // '^\d+(-\d+)?$'
formState.patterns.get((path) => path.birthDate)    // 'yyyy-MM-dd'

// Use in JSX
<label>{formState.descriptions.get((path) => path.auto.modelId)}</label>

Preparing data for an API

schema.toObject(data) strips symbol fields (like z.symbol()) and removes optional fields whose value is an empty string, producing a clean object to send to your backend.

typescript
const payload = schema.toObject(formState.data);
// uuid symbols stripped, optional empty strings omitted
await api.post('/users', payload);

Date values are converted to strings using each field's configured format metadata (defaulting to "yyyy-MM-dd"). This applies to top-level dates, nested dates inside objects, and date elements inside arrays. Send the output straight to JSON-based APIs without an extra serialization pass.

typescript
const schema = z.object({
  birthDate: z.formDate(z.date(), { format: 'MM-dd-yyyy' }),
  registeredOn: z.formDate(z.date(), { format: 'MM/dd/yyyy' }),
  updateDates: z.formArray(z.formDate(z.date())), // default 'yyyy-MM-dd'
});

schema.toObject(formState.data);
/*
{
  birthDate: '12-31-2020',
  registeredOn: '06/30/2020',
  updateDates: ['2019-12-12', '2020-04-15'],
}
*/

Generating a JSON Schema

schema.toJSONSchema() returns a standard JSON Schema (as a string) describing the shape your form produces — the same clean shape you get from toObject(), not the editing shape. It's handy for sharing a contract with a backend, generating API documentation, or validating payloads on the server.

The output mirrors the data contract: required fields are listed in the schema's required array while optional fields are simply left out of it; field constraints such as length, numeric ranges, allowed values, and regular expressions carry through; and each date field becomes a string with a pattern matching its configured format. Symbol fields (like z.symbol()) and editing-only details are omitted.

JSON Schema cannot represent functional checks like z.validate or z.superRefine methods. Therefore, it is important to keep simple validations like min/max/maxLength/gt/gte/lt/lte/format/pattern/required on the field level using simple constraints as shown in the examples.

typescript
const schema = z.object({
  name: z.formString({ required: true }, z.maxLength(50)),
  age: z.formNumber(z.gte(0)),
  category: z.formValues(['standard', 'premium'], { required: true }),
  joined: z.formDate({ required: true, format: 'MM/dd/yyyy' }),
});

schema.toJSONSchema();
/*
{
  "type": "object",
  "properties": {
    "name":     { "type": "string", "minLength": 1, "maxLength": 50 },
    "age":      { "type": "number", "minimum": 0 },
    "category": { "type": "string", "enum": ["standard", "premium"] },
    "joined":   { "type": "string", "pattern": "^(0[1-9]|1[0-2])/(0[1-9]|[12]\\d|3[01])/\\d{4}$", "minLength": 1 }
  },
  "required": ["name", "category", "joined"]
}
*/

Pass false to get compact, single-line output instead of the pretty-printed default — useful when embedding the schema in a request body or a file.

typescript
schema.toJSONSchema();      // pretty-printed (default)
schema.toJSONSchema(false); // compact, single line
Core Concepts

Form Status

High-level booleans about the form as a whole.

PropertyTypeDescription
mode'editable' | 'readOnly' | 'disabled'Current form mode.
readOnlybooleanShorthand for mode === 'readOnly'.
disabledbooleanShorthand for mode === 'disabled'.
dirtybooleanTrue if any field value differs from its initial value.
touchedbooleanTrue if any field has been touched.
validboolean | nullnull before first validation, then true/false.
validSchemaboolean | nullSchema validity ignoring manual errors.
validatingbooleanTrue while an async validation pass (see z.validateAsync) is in flight for the latest data. Always false for schemas without async checks. While validating is true, valid is null.
submittingbooleanTrue while handleSubmit callback is running.
submittedbooleanTrue after a successful submit.
tsx
<button type="submit" disabled={!formStatus.dirty || formStatus.submitting}>
  Save changes
</button>

{formStatus.valid === false && (
  <p>Please fix the errors above before saving.</p>
)}
Note

formStatus.valid is null when validation has never run. This lets you distinguish "not yet validated" from "known valid" without extra state.


Core Concepts

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:

ObjectSource in schemaPrimary use
descriptions.with(z.describe('…'))Drive <label> text from a single source of truth
rangesz.gte / z.lte / z.minLength / z.maxLengthWire min, max, minLength, maxLength on inputs
required{ required: true } option on form fieldsDrive the required attribute and optional asterisk
patternsz.regex(…) on strings, or the dateFormat option on z.formDate(...)Wire the pattern attribute for screen-reader hints, or surface the expected date format
touchedSet 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.

tsx
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:

ShapeField typesAttributes it feeds
type: 'range'Number, datemin, max on input[type=number] / input[type=date]
type: 'length'String, arrayminLength, maxLength on input / textarea; or array add/remove guards
tsx
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.

tsx
// 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. z.formDate() fields also contribute their dateFormat (defaulting to 'yyyy-MM-dd') so you can surface the expected format in placeholders, titles, or labels.

tsx
// Top-level field
formState.patterns.id                            // "^\d+(-\d+)?$" | undefined

// Nested field
formState.patterns.get((path) => path.info.code)      // string | undefined

// Date field — returns the dateFormat (defaults to 'yyyy-MM-dd')
formState.patterns.get((path) => path.birthDate)      // 'MM-dd-yyyy'

// 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)}
/>
Note

When a string 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.

tsx
// 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 without running validation (validation runs by default)
formActions.touch('name', { validate: false });

// Inspect which fields have been touched
formState.touched.getKeys()                      // ['name', 'info.age', ...]
formStatus.touched                               // true if any field is touched
Tip

When 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:

tsx
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>
  );
}
Tip

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].

Core Concepts

Form Groups

Larger forms are often organized into sections — contact details, shipping address, preferences. Form groups let you label related fields in the schema and then pull everything about that section out of the form state in one call.

Wrap a field with z.group(schema, name) to assign it to a group. Groups are only allowed on top-level fields:

tsx
const schema = z.object({
  email: z.group(z.formString({ required: true }), 'contact'),
  phone: z.group(z.formString(), 'contact'),
  street: z.group(z.formString({ required: true }), 'address'),
  city: z.group(z.formString({ required: true }), 'address'),
  newsletter: z.formBoolean(),
});

Call formState.getGroup(name) to get a bundle containing every form-state slice — data, errors, touched, dirty, required, ranges, patterns, and descriptions — narrowed to just that group's fields:

tsx
const contact = formState.getGroup('contact');

contact.data.email;     // 'jane@example.com'
contact.errors.phone;   // error message, if any
contact.touched.email;  // true
contact.dirty.email;    // false
contact.required.email; // true

contact.dirtyGroup;   // false - none of the group fields are dirty
contact.touchedGroup; // true - at least one of the group fields has been touched
contact.validGroup;   // true - validates all the group fields

// Render a whole section from the bundle
<fieldset>
  <legend>Contact</legend>
  {Object.keys(contact.data).map((field) => (
    <div key={field}>
      <label htmlFor={field}>{contact.descriptions[field]}</label>
      <input
        type="text"
        id={field}
        value={contact.data[field]}
        onChange={(e) => formActions.change(field, e.target.value)}
        onBlur={() => formActions.touch(field)}
      />
      {contact.errors[field] && <span>{contact.errors[field]}</span>}
    </div>
  ))}
</fieldset>

Each bundle contains only the fields in that group — the address group never exposes email, and ungrouped fields such as newsletter appear in no group at all. The returned objects are read-only, just like the rest of the form state.

This also makes groups a natural fit for forms shown one section at a time, such as a multi-step wizard: render a single group per step while the rest of the form state stays intact in the background, then submit the whole form once every step is complete.

The group name is checked against the schema, so requesting a group that does not exist is a TypeScript error and autocomplete suggests the valid names.

Note

Groups only apply to top-level fields. Placing a group on a nested object field or an array element is reported by the no-nested-group ESLint rule and throws when the form initializes.

Tip

Groups can be used as a selector's first argument to memoize the underlying slice of the form state.

Features

Array Fields

The formActions.array namespace provides all the operations you need to manage dynamic lists without manual index juggling.

typescript
// Append one or more items
formActions.array.append('tags', ['react', 'typescript']);
formActions.array.append((path) => path.notes, [{ text: '', typeId: '' }]);

// 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.

tsx
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>
Tip

All array methods accept an optional { touch, validate, callback } options object — same interface as formActions.change.

Features

Validation

Validation runs automatically on every change and touch call by default. You control when errors surface to users through the touched pattern: only show an error once the field has been touched. Running validation on touch ensures empty required fields light up after a user tabs through them without typing.

Validation options

OptionDefaultDescription
validateOnMountfalseRun validation immediately when the hook mounts.
validateOnChangetrueRe-validate after every change call.
validateOnTouchtrueRun validation when a field is touched.
validateBeforeSubmittrueValidate before treating a submit as successful.

Validating on demand

typescript
// 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.

Auto-touch on error

When formActions.validate runs, every field that has an error — whether from the schema or from a manual error keyed to a real field — is automatically marked as touched. The same happens when formHandlers.handleSubmit encounters errors.

If your UI renders errors only for touched fields, every error becomes visible as soon as the user submits the form or runs full form validation.

Validating async schemas with validateAsync

If your schema contains async checks (for example, z.validateAsync for server-side uniqueness), use formActions.validateAsync instead of validate. It resolves to true when the form is valid and false otherwise.

typescript
const isValid = await formActions.validateAsync();

if (!isValid) {
  // Branch on isValid — formState.errors and formStatus.valid in this
  // closure are from the previous render and may not reflect the result.
  return;
}

validateAsync also works on sync schemas, so it's safe to use whenever you want to await a boolean result. For full form submission with an async schema, use formHandlers.handleSubmit — it already awaits validation before submitting the form.

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:

tsx
<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):

typescript
// 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:

typescript
useFormState(schema, {
  initialData: existingRecord,
  initialTouched: ['name', 'email', (path) => path.info.age],
});
Features

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.

tsx
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) {
      // Surface a form-level error under any key you choose (here: serverError)
      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:

typescript
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' });

// Blur the currently focused element — useful to dismiss
// the on-screen keyboard on mobile after a successful submit,
// or to move focus out of the form before opening a modal.
formActions.blur();

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 handlingAutomatic — fields revert to their initial state on form resetMust wire onReset={formHandlers.handleReset} manually
React 19 post-submit reset prevention 1Includes <FormResetBlocker> automaticallyMust place <FormResetBlocker> inside the <form> manually
Native validation suppressionSets noValidate by default — browser popups never appearMust add noValidate manually or browser bubbles will show
Form data cache supression 2Sets autoComplete="off" by defaultMust add autoComplete="off" manually or browser can cache form data that is inconsistent with the form state data.
Enter-key submit preventionBlocks accidental submit-on-Enter from single-line inputs (opt in with submitWithEnter)Browser default — Enter submits the form from any input
useWatch supportAutomatic — useWatch subscribers stay in syncformHooks.useWatch will not receive updates
SecureInput in FormDataIntercepts the formdata event and patches FormData with secure valuesSecureInput values will be missing from FormData
Submit button value in FormDataTracks event.submitter and ensures the button's name/value is always includedBrowser behaviour varies — may omit the submitter value
  1. React 19 resets unmanaged form fields after the submit action, which is undesirable when the form is driven by client-side state. <FormResetBlocker> intercepts and cancels that reset.
  1. The autoComplete attribute/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.

tsx
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>
);
Limitations

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:

typescript
// 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.

tsx
// 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;
};
Note

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:

tsx
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>
Note

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.submittinguseFormStatus().pending
SourceFormState internal stateReact 19 form context
Where to useSame component as useFormStateAny child component inside <Form>
Use caseDisabling the submit buttonShowing 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.

typescript
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;
};

Last submitted data

formState.submittedData (or formActions.getSubmittedData() from a callback) returns the { data, formData } snapshot from the most recent successful submit, or null if none. Useful for multi-step wizards advancing on each step's submit, and rollback flows that need the last-known-good payload.

tsx
const submission = formState.submittedData;

// populate the wizard, undo some changes, etc.
Note

formData is null when the submit was triggered programmatically via formActions.validate({ submit: true }) rather than an HTML form action — the field is only populated for React-driven submits through <form action>.

Features

Manual Errors

Set errors outside of schema validation — server errors, async uniqueness checks, or cross-field constraints — using formActions.setError.

The key is yours to choose. Use a field name to attach the error to that field, or any other string (like serverError) for a form-level message that isn't tied to one input.

typescript
// 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 === 'serverError',
});

Read manual errors with errors.getManual(key):

tsx
{formState.errors.getManual('serverError') && (
  <div className="form-error">
    {formState.errors.getManual('serverError')}
  </div>
)}
Note

Manual errors live alongside schema errors. formStatus.valid is false when either type is present; formStatus.validSchema reflects only the Zod schema result.

Features

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.

typescript
// 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.

tsx
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.

tsx
<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

tsx
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)}
/>
Features

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.

tsx
// Generates e.g.: "form-state__required form-state__touched form-state__error"

<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', { prefix: 'my-form' });
// "my-form__required my-form__error"

Prefix classes

The prefix classes are emitted in the order shown below, before any classes produced by the second argument.

ClassApplied when
[prefix]__requiredField is marked required by the schema.
[prefix]__disabledForm is in disabled mode.
[prefix]__readonlyForm is in readOnly mode.
[prefix]__touchedField has been touched. Only emitted in editable mode.
[prefix]__errorField has a validation error after the form has been validated. Suppressed in disabled mode.

The default prefix is 'form-state'. Setting it to null disables prefix classes entirely — no classes are generated automatically.

Initialize CSS options globally via cssOptions:

typescript
// Just the prefix.
useFormState(schema, { cssOptions: { prefix: 'my-form' } });

// Prefix plus a static `classNames` string applied to every field.
useFormState(schema, {
  cssOptions: {
    prefix: 'my-form',
    classNames: 'block w-full rounded border px-3 py-2',
  },
});

// Prefix plus a callback driving classes from the field state.
useFormState(schema, {
  cssOptions: {
    prefix: 'my-form',
    classNames: ({ isError, isTouched }) =>
      isError && isTouched ? 'border-red-400' : 'border-zinc-300',
  },
});

cssOptions accepts a prefix and a classNames value. Set them once and they apply to every formClasses(field) call. A per-call argument fully replaces the form-level value.

typescript
const { formClasses } = useFormState(schema, {
  cssOptions: {
    prefix: 'my-form',
    classNames: ({ isError }) => ['field', isError && 'field--invalid'],
  },
});

formClasses('name');
// "my-form__required my-form__error field field--invalid"

formClasses('name', ({ isError }) => isError && 'field--server-error');
// "my-form__required my-form__error field--server-error"
// The per-call callback fully replaces the form-level `classNames`.

Set cssOptions.prefix: null to disable prefix classes entirely. The callback still runs, so you can drive all styling from classNames alone. When the prefix is null, formActions.focusOnFirstError() falls back to locating the first errored field by its name attribute.

typescript
useFormState(schema, { cssOptions: { prefix: null } });

Adding custom classes

formClasses(field, ...) accepts four shapes for its second argument:

  • Omitted — emits the prefix classes only.
  • A string — appended verbatim, always applied.
  • A FormClassCallback — receives { isError, isTouched, isRequired, mode } and returns a clsx-compatible value: a string, an object (keys with truthy values are kept), an array (flattened recursively), or any falsy value (false, null, and undefined are filtered out).
  • A FormClassOptions object with prefix and/or classNames.
tsx
// 1) Static string — applied regardless of state.
<input className={formClasses('name', 'block w-full rounded border px-3 py-2')} />

// 2) Callback returning a string.
<input
  className={formClasses('name', ({ isError, isTouched }) =>
    isError && isTouched
      ? 'border-red-400 bg-red-50/50'
      : 'border-zinc-300 bg-white'
  )}
/>

// 3) Callback returning a clsx-style object.
<input
  className={formClasses('email', ({ isError, isTouched }) => ({
    'form-control': true,
    'is-invalid': isError && isTouched,
  }))}
/>

// 4) Callback returning an array (falsy entries are dropped).
<input
  className={formClasses('name', ({ isError, isTouched, mode }) => [
    'block w-full rounded border px-3 py-2',
    mode === 'disabled' && 'bg-slate-100 text-slate-400 cursor-not-allowed',
    mode === 'readOnly' && 'bg-slate-50 text-slate-600',
    isTouched && 'border-slate-400',
    isError && isTouched && 'ring-1 ring-red-500',
  ])}
/>

// 5) Options object — custom prefix plus a callback.
formClasses('name', {
  prefix: 'fld',
  classNames: ({ isError }) => ['field', isError && 'field--invalid'],
});
// e.g. "fld__required fld__error field field--invalid"

The utility classes in the snippets above (block, border-red-400, bg-slate-50, etc.) are Tailwind CSS classes, used purely for illustration. FormState has no opinion on the class names you return — substitute Bootstrap, your own CSS, or any other utility framework as appropriate.


Resolving class conflicts

FormState does not deduplicate or resolve conflicts between the classes you return. If two of your branches can produce conflicting classes for the same CSS property, gate them through mutually exclusive conditions in the callback — the ternary or object form above — so only one ever reaches the output.

Standalone classNames helper

classNames is the same clsx-style resolver that powers formClasses, exported for use outside of form fields. Pass it any number of FormClassValue arguments — each one a string, a clsx-compatible object, an array of either, or a falsy value — and it returns a space-separated class name string. Object keys with truthy values are kept; arrays are flattened recursively; false, null, and undefined are filtered out.

typescript
import { classNames } from 'form-state';

// String — returned as-is.
classNames('btn btn-primary');
// "btn btn-primary"

// Object — keys with truthy values are kept.
classNames({ 'btn': true, 'btn-primary': isPrimary, 'is-disabled': disabled });
// e.g. "btn btn-primary"

// Array — flattened recursively, falsy entries dropped.
classNames(['btn', isPrimary && 'btn-primary', { 'is-loading': loading }]);
// e.g. "btn btn-primary is-loading"

// Sequence — combine any mix of strings, objects, arrays, and falsy values.
classNames(
  'btn',
  isPrimary && 'btn-primary',
  { 'is-loading': loading, 'is-disabled': disabled },
  [size && `btn-${size}`, extraClass]
);
// e.g. "btn btn-primary is-loading btn-lg my-extra-class"

// Falsy input — returns an empty string.
classNames(null);
// ""

// No arguments — returns an empty string.
classNames();
// ""
Features

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.

typescript
// 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);
tsx
// 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>
  );
}
tsx
// 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,
  watch: true,  // enable useWatch
})(AppForm);

In React DevTools, the wrapped component is tagged formConnected. This makes it easy to spot which component in the tree owns the form's provider.

Warning

useFormStateContext must receive the same schema reference that was passed to the provider. A mismatched schema throws at runtime in development.

Features

Input Controls

Drop-in input components that pair with useFormState for common value-shaping needs.

MaskedInput

MaskedInput restricts user input to a fixed pattern of slots and literal characters and always renders the full mask in place. Typing into a literal position jumps to the next slot; deleting a character clears the slot to its placeholder character (e.g. _) without shifting the rest of the input.

tsx
import { MaskedInput } from 'form-state';

<MaskedInput
  mask="(999) 999-9999"
  onChange={(event) => {
    formActions.change('phone', event.target.value);
    if (event.complete) {
      // Fires on the change that fills every required slot
    }
  }}
/>

Mask tokens

The mask prop is composed of tokens (slots that accept user input) and literal characters that are rendered as-is and skipped over while typing.

TokenAccepts
9A single digit (0–9)
aA single letter (A–Z, a–z)
*A single alphanumeric character
?Marks every following position as optional — they accept input but are not required for event.complete to flip to true. Useful for trailing extensions like (999) 999-9999? x99999.
any other characterTreated as a literal — parentheses, dashes, slashes, dots, commas, colons, spaces, etc.

Customizing the placeholder

Slot positions display _ by default. Use the placeholder prop to override the character shown at unfilled slots — provide a string aligned with the rendered mask. Literal positions in the placeholder are ignored; the mask's literal always wins.

tsx
<MaskedInput mask="99/99/9999" placeholder="DD/MM/YYYY" />

For slot positions not covered by placeholder, set placeholderChar to '_' (default) or ' '. A space keeps the mask visually invisible until the user types into it, while still reserving the layout.

tsx
<MaskedInput mask="(999) 999-9999" placeholderChar=" " />

Input type and mobile keyboard

type is constrained to 'text' (default) or 'search' — types that respect the formatted mask string. Other native types like number or email would strip mask literals or apply conflicting validation, so they are intentionally not allowed.

inputMode is auto-detected from the mask: a digit-only mask (no a or * tokens) gets inputMode="numeric" so mobile users see the number pad without any extra config. Pass inputMode explicitly to override.

Value semantics

value, defaultValue, and onChange all use the formatted string with placeholder characters at unfilled slots — exactly what is rendered in the DOM. The one exception: when no slot has been filled, the value is the empty string (so a freshly-rendered field doesn't submit a placeholder template through FormData).

  • Empty: "" (browser shows the empty mask via the HTML placeholder)
  • Partial: "(555) 1__-____"
  • Complete: "(555) 123-4567"

Change and blur events

Both onChange and onBlur receive an event extended with two extra fields:

  • event.complete — true when every required mask slot is filled at the moment the event fires, otherwise false. Optional slots (declared with ?) do not need to be filled.
  • event.unmaskedValue — the user-entered characters concatenated in slot order, with literals and unfilled-slot placeholders stripped. Useful when you want to store or submit the raw digits/letters without the mask.
tsx
<MaskedInput
  mask="(999) 999-9999"
  onChange={(event) => {
    // event.target.value   = "(555) 123-4567"
    // event.unmaskedValue  = "5551234567"
    // event.complete       = true
  }}
  onBlur={(event) => {
    if (!event.complete) {
      formActions.touch('phone', { error: 'Please enter a full phone number' });
    }
  }}
/>

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.

tsx
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 onChange for uncontrolled usage.
Features

Debounced Changes

Pass debounceIntervalMs to change to delay downstream effects — useful for search inputs, async field validation, or expensive re-renders.

typescript
// Define the callback at module level so it has a stable reference.
function onSearch(state: FormState<FormSchema>, status: FormStatus) {
  // Fires 400ms after the last keystroke
  performSearch(state.data.search);
}

formActions.change('search', inputValue, {
  debounceIntervalMs: 400,
  touch: true,
  callback: onSearch,
});

The callback always receives the latest (state, status) snapshot — even when multiple rapid changes fired before the debounce elapsed.

Warning

When you pass a callback alongside debounceIntervalMs, give it a stable reference — wrap it in useCallback or define it outside the component. An inline arrow function is recreated on every render and is flagged by the stable-debounced-callback ESLint rule.

Features

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.

FormatPath info.ageArray item items[0].titleTop-level name
'bracket' (default)info["age"]items[0]["title"]name
'dot'info.ageitems.0.titlename

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.

tsx
// 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:

tsx
const { Form, formActions } = useFormState(schema, {
  inferredNameFormat: 'dot', // all inferName calls default to dot notation
});
Features

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.

typescript
import { convert } from 'form-state';

// Optional number to number type conversion
convert.asNumber(1);          // 1
convert.asNumber('');         // 0
convert.asNumber('', 100.0);  // 100.0

// Safe numeric parsing — returns the number or '' if invalid
convert.toInt('42')          // 42
convert.toFloat('3.14')      // 3.14
convert.toInt('abc')         // ''

// Date to string type conversion
convert.asDateString('')                                 // ''
convert.asDateString('2022-01-01')                       // '2022-01-01'
convert.asDateString(new Date(2022, 0, 1))               // '2022-01-01'
convert.asDateString(new Date(2022, 0, 1), 'MM/dd/yyyy') // '01/01/2022'

// 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

typescript
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

typescript
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: null

Dirty flag for non-field state

Track external state (e.g. a file upload) alongside form state using formActions.setDirty. Keys must start with #.

typescript
// 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.

typescript
import { parseState } from 'form-state';

const result = parseState(formSchema, apiResponse);

if (result.success) {
  formActions.replace(result.data);
} 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);
}
Async schemas

If your schema contains async checks (such as z.validateAsync), parseState will throw — Zod's synchronous parse can't resolve a promise. Use parseStateAsync instead — same API, returns a promise.

typescript
import { parseStateAsync } from 'form-state';

const result = await parseStateAsync(formSchema, apiResponse);
Hooks

Memoized Selector (useSelector)

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.

tsx
const { formState, formHooks } = useFormState(schema);

// Extract a field as-is
const selectName = formHooks.useSelector(state => state.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)  // boolean

Composable 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.

tsx
// 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.

tsx
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.

Selecting by group

Pass a group name as the first argument to select that group's data slice. With no second argument the selector returns the group's data fields; with a second selector, it runs over the group's data — scoped so fields outside the group are a compile error. The result is memoized like any other selector.

typescript
// Whole data slice for the 'contact' group
const selectContact = formHooks.useSelector('contact');
const contact = selectContact(formState.data);
// { email: string; phone: string }   (only the group's fields)

// Scoped selector — input is narrowed to the group's data
const selectEmailDomain = formHooks.useSelector('contact', (data) =>
  data.email.split('@')[1]
);
const domain = selectEmailDomain(formState.data);

// Group slice as the data source
const domainFromSlice = selectEmailDomain(formState.getGroup('contact').data);

// formHooks.useSelector('contact', (data) => data.age)
//   ^ compile error: 'age' is not in the 'contact' group
Hooks

State Listener (useListener)

Subscribe to form state changes from inside a component without triggering extra re-renders.

typescript
import { type StateChangeListener } from 'form-state';

const listener: StateChangeListener<FormSchema> = ({ type, data }) => {
  console.info(type, data);
};

function AppForm() {
  formHooks.useListener(listener);
  // ...
}

The event object:

PropertyTypeDescription
type'change' | 'submit' | 'asyncValidating' | 'asyncValidated'What triggered the event. The async pair fires once per active async refinement around each validation pass — one asyncValidating and one asyncValidated per check.
dataTForm data snapshot. For change-driven async events: asyncValidating carries the pre-change state (so listeners can read the value being replaced), and asyncValidated carries the post-change state with the freshly resolved async errors applied.
formDataFormData?Native FormData — only on submit events.
submitCountnumberHow many times the form has been submitted.
errorsRecord<string, string>Current error map.
triggerFieldstring | undefinedAsync events only. Dot-notation path of the field whose change started the pass (e.g. 'name', 'users.0.email'). undefined for programmatic validateAsync() and other non-change triggers.
schemaPathstringAsync events only. Dot-notation path of the async refinement that fired this event. Property-level checks report the field path (e.g. 'username', 'users.0.email'); top-level checks attached to the root object report an empty string ''. When a schema declares multiple async checks, each active check produces its own asyncValidating / asyncValidated pair with the matching schemaPath.

Use the async events to drive a loading indicator next to a field while a server-side check is in flight — for example, a username uniqueness lookup that hits an API. The event pair is debounced for you: only one asyncValidating fires per burst of overlapping passes, so the indicator doesn't flicker while the user keeps typing. Checks gated by skipWhen or submitOnly are filtered out of this fanout, so listeners only see events for the refinements that actually ran.

typescript
import { useState } from 'react';
import { type StateChangeListener } from 'form-state';

function UsernameForm() {
  const [validatingField, setValidatingField] = useState<string | null>(null);

  const listener: StateChangeListener<FormSchema> = (event) => {
    // `schemaPath` identifies which async refinement fired — match by the
    // refinement's attachment path so multiple async checks don't interfere.
    if (event.type === 'asyncValidating' && event.schemaPath === 'username') {
      setValidatingField('username');
    } else if (event.type === 'asyncValidated' && event.schemaPath === 'username') {
      setValidatingField(null);
    }
  };

  const { formHooks, formActions } = useFormState(schema);
  formHooks.useListener(listener);

  return (
    <label>
      Username
      <input
        name={formActions.inferName('username')}
        onChange={(e) => formActions.change('username', e.target.value)}
      />
      {validatingField === 'username' && <span>Checking availability…</span>}
    </label>
  );
}
Hooks

Field Watch (useWatch)

formHooks.useWatch(name, compute?) observes the raw DOM value of a named input as the user types — without needing a controlled value prop. It subscribes via useSyncExternalStore, bypassing React's normal render cycle so high-frequency reads don't trigger re-renders in unrelated components. Requires watch: true in the hook options.

This is the signal pattern applied to form fields: fine-grained subscriptions where only the components that read a value re-render — the same model Solid signals and Preact signals use to isolate updates from the surrounding tree.

typescript
// 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 : '')
);
Note

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.

Hooks

Navigation Blocker (useBlocker)

A user fills out half a form, then clicks a link or hits the back button — and their edits are gone. formHooks.useBlocker lets you catch that moment and ask "are you sure?" before the navigation goes through.

The API is loosely based on React Router's useBlocker: it returns the same proceed() / reset() controls and a blocked state you check before confirming. The difference is that FormState's version is router-agnostic, so instead of receiving the route being navigated to, your function receives the form's own state and status — making "block while the form has unsaved changes" a one-liner:

tsx
const { blockerState, guard, proceed, reset } = formHooks.useBlocker(
  (state, status) => status.dirty
);

This guards navigation in two places:

  • Leaving the page entirely (closing the tab or reloading) is handled for you — the browser's native "leave site?" prompt appears automatically whenever your function returns true. Nothing else to wire up.
  • Moving around inside your app is up to you, because FormState doesn't know which router you use. Route your navigation through guard instead of calling it directly. If it's safe to leave, the navigation happens immediately; if not, it's held and blockerState flips to 'blocked' so you can show a confirmation. From there, proceed() lets the navigation through and reset() cancels it.
tsx
import { useFormState } from 'form-state';
import { navigate } from 'router'; // whichever React router you use

const { formHooks } = useFormState(schema, { initialData });
const { blockerState, guard, proceed, reset } = formHooks.useBlocker(
  (state, status) => status.dirty
);

// Instead of navigating directly, go through the guard:
<button onClick={() => guard(() => navigate('/next'))}>Next</button>

// When a navigation is held, show your own confirmation UI:
{blockerState === 'blocked' && (
  <div role="alertdialog">
    <p>You have unsaved changes. Leave anyway?</p>
    <button onClick={proceed}>Leave</button>
    <button onClick={reset}>Stay</button>
  </div>
)}

Because guard accepts any () => void callback, it isn't tied to a router at all — you can drive the browser History API directly. Route every pushState through the guard, and intercept the back / forward buttons via popstate:

tsx
import { useEffect } from 'react';
import { useFormState } from 'form-state';

const { formHooks } = useFormState(schema, { initialData });
const { blockerState, guard, proceed, reset } = formHooks.useBlocker(
  (state, status) => status.dirty
);

// Forward navigation: push through the guard instead of calling history directly.
const go = (url: string) => guard(() => history.pushState({}, '', url));

// Back / forward buttons: re-push the current URL, then guard the real move.
useEffect(() => {
  const onPopState = () => {
    history.pushState({}, '', location.href); // undo the browser's move first
    guard(() => history.back());              // then ask before actually leaving
  };
  window.addEventListener('popstate', onPopState);
  return () => window.removeEventListener('popstate', onPopState);
}, [guard]);

<button onClick={() => go('/next')}>Next</button>
{blockerState === 'blocked' && (
  <Confirm onYes={proceed} onNo={reset} />
)}

The confirmation dialog is entirely yours — FormState only tells you when to show it, never how. If you'd rather skip the browser's native prompt (which covers closing the tab and reloading the page) and only guard in-app navigation, pass { enableBeforeUnload: false } as the second argument.

useBlocker is router-agnostic by design — it never ties FormState to a particular router, which is why your function receives the form's state and status rather than the route being navigated to. To block a specific route, do the check inside shouldBlock and send your router's navigation through guard.

Integration

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

ts
"use server";

import { parseState, z } from "form-state";
import { schema } from "./contact-form";

export async function submitContact(data: z.infer<typeof schema>) {
  const result = parseState(schema, data, true);

  if (!result.success) {
    throw new Error(result.errors.getAll().join(" | "));
  }

  await db.contacts.create({ data: result.data });

  return { success: true };
}

Form component

tsx
"use client";

import { useFormState, z } from "form-state";
import { submitContact } from "./actions/submit-contact";

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);
    } catch (error) {
      return { serverError: error instanceof Error ? 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>
  );
}
Tip

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.

tsx
// 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 Error("Unexpected user data shape");
  }

  return <ContactForm user={result.data} />;
}

Update ContactForm to accept and forward the prop:

tsx
"use client";

import { useFormState, z } from "form-state";
import { submitContact } from "./actions/submit-contact";

// 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
}
Integration

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

ts
// 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

tsx
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 {};
    }

    // Without experimental RSC support, use fetch (or a client like Axios)
    // to call the route action.
    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>
  );
}
Tip

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.

Server action (experimental RSC)

With React Router's experimental RSC Framework Mode, you can skip the route action and call a "use server" function directly — the same ergonomics as Next.js or TanStack Start. Mark the form "use client" and replace the fetch with a direct call.

Experimental

RSC Framework Mode is unstable. It requires the unstable_reactRouterRSC Vite plugin (with @vitejs/plugin-rsc); the route-action approach above remains the stable default.

ts
// app/actions/submit-contact.ts
"use server";

import { parseState, z } from "form-state";
import { schema } from "../contact-form";

export async function submitContact(data: z.infer<typeof schema>) {
  const result = parseState(schema, data, true);

  if (!result.success) {
    throw new Error(result.errors.getAll().join(" | "));
  }

  await db.contacts.create({ data: result.data });

  return { success: true };
}

In the "use client" form component, call it from handleSubmit:

tsx
import { submitContact } from "./actions/submit-contact";

const handleSubmit = formHandlers.handleSubmit(async (state) => {
  if (!state.valid) {
    formActions.focusOnFirstError({ selectText: true });
    return {};
  }

  try {
    await submitContact(state.data);
  } catch (error) {
    return { serverError: error instanceof Error ? error.message : "Server error" };
  }

  return true;
});

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.

tsx
// 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 Error("Unexpected user data shape");
  }

  return result.data;
}

export default function ContactRoute({ loaderData }: Route.ComponentProps) {
  return <ContactForm user={loaderData} />;
}

Update ContactForm to accept and forward the prop:

tsx
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
}
Integration

TanStack Start

FormState works naturally with TanStack Start. The form component validates on the client, then calls a typed createServerFn directly from handleSubmit — no manual fetch or JSON handling, and the return value is fully typed.

Note

This example is included for completeness. If you're already on the TanStack stack, you're more likely to reach for TanStack Form — FormState is shown here to demonstrate that it drops into TanStack Start the same way it does into Next.js or React Router.

Server action

ts
// src/actions/submit-contact.ts
import { createServerFn } from "@tanstack/react-start";
import { parseState, z } from "form-state";
import { schema } from "../contact-form";

export const submitContact = createServerFn({ method: "POST" })
  .inputValidator((data: z.infer<typeof schema>) => data)
  .handler(async ({ data }) => {
    const result = parseState(schema, data, true);

    if (!result.success) {
      throw new Error(result.errors.getAll().join(" | "));
    }

    await db.contacts.create({ data: result.data });

    return { success: true };
  });

Route

tsx
// src/routes/contact.tsx
import { createFileRoute } from "@tanstack/react-router";
import ContactForm from "../contact-form";

export const Route = createFileRoute("/contact")({
  component: ContactRoute,
});

function ContactRoute() {
  return <ContactForm />;
}

Form component

tsx
import { useFormState, z } from "form-state";
import { submitContact } from "./actions/submit-contact";

export 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({ data: state.data });
    } catch (error) {
      return { serverError: error instanceof Error ? 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>
  );
}

Pre-filling from a loader

Add a loader to the route to fetch data on the server and pass it to the form as initialData — for example, pre-filling fields from the current user's profile.

tsx
// src/routes/contact.tsx
import { createFileRoute } from "@tanstack/react-router";
import { parseState } from "form-state";
import ContactForm, { schema } from "../contact-form";

export const Route = createFileRoute("/contact")({
  component: ContactRoute,
  loader: async () => {
    const user = await db.users.findFirst({ where: { id: await getSessionUserId() } });
    const result = parseState(schema, user);

    if (!result.success) {
      throw new Error("Unexpected user data shape");
    }

    return result.data;
  },
});

function ContactRoute() {
  const loaderData = Route.useLoaderData();
  return <ContactForm user={loaderData} />;
}

Update ContactForm to accept and forward the prop:

tsx
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
}
Integration

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.

bash
npm install @tanstack/react-query

Complete example

tsx
import { useSuspenseQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useFormState, parseState, z, type SchemaDataObject, type SubmitState } from 'form-state';

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: 'MM/dd/yyyy' }).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 with the loaded server data
  const { Form, formState, formStatus, formActions, formHandlers } =
    useFormState(profileSchema, {
      initialData: data,
    });

  // 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={formState.patterns.dateOfBirth}
          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.

tsx
const { data } = useSuspenseQuery({
  queryKey: ['profile', userId],
  queryFn:  () => api.getProfile(userId),
});

// data is always ProfileData here — the component suspends until resolved
useFormState(profileSchema, { initialData: data });
Dirty-field protection

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.

tsx
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>:

tsx
<ErrorBoundary fallback={<ErrorMessage />}>
  <Suspense fallback={<Skeleton />}>
    <ProfileForm userId={userId} />
  </Suspense>
</ErrorBoundary>

Inside ProfileForm, data is always defined — no isPending or isError guards needed.

Note

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.

tsx
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.

tsx
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.

tsx
// 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')} />
Note

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.

Tools

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).

bash
npm install github:dstarosta/FormStateTools#v1.0.0

Setup

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.

Bundler setup

This works in any React app but increases the bundle size. For Next.js and Vite, see the configuration below.

On Create React App or any other bundler, this manual setup is all you need.

tsx
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} />
    </>
  );
}

Next.js

On Next.js, load FormDock with next/dynamic and show it only in development. This keeps form-state-tools out of your production bundle.

tsx
'use client';

import dynamic from 'next/dynamic';

const FormDock = dynamic(
  () => import('form-state-tools').then(({ FormDock }) => FormDock),
  { ssr: false },
);

function AppForm() {
  const form = useFormState(schema);   // or useFormStateContext(schema)
  const { Form, formHandlers } = form;

  return (
    <>
      <Form action={formHandlers.handleSubmit(onSubmit)}>
        {/* ... */}
      </Form>

      {process.env.NODE_ENV === 'development' && <FormDock form={form} />}
    </>
  );
}

Vite plugin (auto-inject)

If you use Vite, the form-state-tools/vite plugin can mount the dock for you — no <FormDock> in your JSX. Add it to your Vite config and feed it your form state with the useFormDock hook. The plugin auto-detects how to inject the dock: it uses the HTML entry for plain Vite/React SPAs, the framework client entry for TanStack Start, and the root module (root.tsx) for React Router 7 (Remix).

ts
// vite.config.ts
import { defineConfig } from 'vite';
import formStateTools from 'form-state-tools/vite';

export default defineConfig({
  plugins: [
    formStateTools(),
    // ...your other plugins
  ],
});

Then call useFormDock in your form, passing the same value you would pass to the form prop. The dock the plugin injects subscribes to whatever you report:

tsx
import { useFormDock } from 'form-state-tools';

function AppForm() {
  const form = useFormState(schema);   // or useFormStateContext(schema)
  const { Form, formHandlers } = form;

  useFormDock(form);   // feeds the plugin-injected dock — no <FormDock> needed

  return (
    <Form action={formHandlers.handleSubmit(onSubmit)}>
      {/* ... */}
    </Form>
  );
}

useFormDock takes an optional second argument that controls whether the dock is shown. It defaults to true (always shown):

tsx
// Only show the dock when the URL carries `?devtools=true`.
useFormDock(form, new URLSearchParams(location.search).get('devtools') === 'true');
Tip

For an SSR framework whose client entry is not auto-detected, point the plugin at your client-entry module with the clientEntry option — a substring matched against module ids (e.g. formStateTools({ clientEntry: 'entry-client' })). This must name a JavaScript module, not an HTML file. When set, the dock is mounted only from that module.

You do not need it for SPAs, TanStack Start, or React Router 7 (Remix) — all are auto-detected.

Plugin options

Pass an options object to formStateTools() to control where the dock is injected and how it behaves. Every option is optional; omit them to accept the defaults.

ts
formStateTools({
  collapsed: false,
  captureErrors: 'console',
  ignoreErrorPatterns: [/ResizeObserver/, 'Network request failed'],
})
OptionTypeDefaultDescription
enabledbooleandev onlyOverrides the automatic dev-only behavior. Set true to inject the dock even in non-dev builds, or false to turn the plugin off entirely.
clientEntrystringauto-detectedA substring matched against module ids to locate your app's client-entry module. Only needed for SSR setups whose client entry is not auto-detected. Must name a JavaScript module, not an HTML file.
collapsedbooleantrueWhether the dock starts in its collapsed state.
captureErrors'all' | 'console' | 'thrown' | 'none''all'Which kinds of errors the dock surfaces as an on-screen toast: thrown errors, console.error calls, both, or none.
ignoreErrorPatterns(string | RegExp)[][]Patterns for errors the dock should not show. An error is ignored when its message matches any of these patterns. RegExp patterns keep their flags.

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.

Minimized (default) — not yet validated
EXPAND FORM TOOLS
Minimized — form invalid
EXPAND FORM TOOLS

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:

COLLAPSE FORM TOOLS
▼ form [3 keys]
▼ initialState [2 items]
▼ data [7 keys]
► auto [2 keys]
id : "100-1"
► notes [2 items]
password : ""
priority : ""
rating : ""
sendEmail : "no"
▼ errors [0 keys]
▼ state [8 keys]
▼ data [7 keys]
► auto [2 keys]
id : "100-1"
► notes [2 items]
password : ""
priority : true
rating : ""
sendEmail : "no"
▼ descriptions [13 keys]
. : "Form"
auto : "Auto"
auto.automakerId : "Brand"
auto.modelId : "Model"
id : "The ID"
notes : "Notes"
priority : "Priority Request"
rating : "Rating"
sendEmail : "Send Email?"
► dirty [7 keys]
► errors [0 keys]
► patterns [1 key]
► ranges [5 keys]
► required [11 keys]
► touched [7 keys]
▼ status [9 keys]
dirty : false
disabled : false
mode : "editable"
readOnly : false
submitted : false
submitting : false
touched : false
valid : true
validSchema : true

When validation errors are present, error keys are highlighted and the header icon turns red:

COLLAPSE FORM TOOLS
▼ form [3 keys]
▼ initialState [2 items]
► data [7 keys]
▼ errors [1 key]
priority : "Priority cannot be indeterminate."
▼ state [8 keys]
► data [7 keys]
► descriptions [13 keys]
► dirty [7 keys]
▼ errors [1 key]
priority : "Priority cannot be indeterminate."
► patterns [1 key]
► ranges [5 keys]
► required [11 keys]
► touched [7 keys]
▼ status [9 keys]
dirty : false
disabled : false
mode : "editable"
readOnly : false
valid : false
validSchema : false

Detach into a popup

The header's left-most icon button toggles between the in-page dock and a separate browser popup window. Click the detach icon to move the panel into its own window.

Attached — click to detach
EXPAND FORM TOOLS
Detached — popup header shows attach icon
FORM TOOLS DETACHED

The popup opens centered, sized to 65% of the screen width × 500px tall, and inherits the host page's stylesheets so the JSON tree looks identical. The dock remembers its size, and you can close it by clicking the attach icon, clicking the header, or navigating away.

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

PropTypeDefaultDescription
formFormStateResponse—The object returned by useFormState or useFormStateContext. Required.
devModebooleanautoOverride render behavior. Unset = auto-detect from NODE_ENV.
collapsedbooleantrueInitial 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 / KeyEffect
Click headerToggle between minimized and normal (30% of screen height)
Right-click headerToggle between normal and maximized (full screen height)
Click the detach / attach icon (left of header)Detach the panel into a popup window, or re-attach it. See Detach into a popup above for all the ways to close the popup.
SpaceToggle minimized ↔ normal
EnterToggle normal ↔ maximized
Escape (toast)Dismiss the error toast

Configuration examples

tsx
// 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} />
Production safety

When NODE_ENV=production, FormDock returns null immediately. No panel markup, no error listeners, no bundle overhead beyond the conditional check.