v1
Getting Started

FormState Guide

FormState is a type-safe React form management library that pairs with Zod Mini for schema validation. It gives you full control over field state — values, errors, dirty flags, touched tracking, ranges, and more — without forcing you to adopt a specific rendering pattern.

Key traits:

  • Schema-first — one Zod schema drives types, validation, constraints, and metadata.
  • Path expressions — address nested and array fields with typed lambda paths: (path) => path.info.address.
  • End-to-end type safety — generics flow from schema to every method signature, callback, and state accessor.
  • No magic re-renders — you call change, you read formState.data, React re-renders normally.
  • React 19 ready — works with server actions, useFormStatus, and the new action prop on <form>.
  • 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';

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 — unstable references, incorrect schema primitives, and unsafe password inputs. recommended is the only available config and enables all rules.

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

Add formStatePlugin.configs.recommended to the extends array of your flat config:

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-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
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
stable-listenerEnforces stable listener references passed to useListener(). Inline functions create a new reference on every render, causing the subscription to unsubscribe and resubscribe continuously.error
use-form-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.

TypeScript First

Types flow end-to-end from your schema through every method signature, callback, and component prop. There are no any casts, no silent type widening, and no generics to pass by hand — inference does the work.

Path expressions are type-checked at compile time. (path) => path.info.age is valid or it isn't, and the error lands on your input's prop — not inside a render-prop wrapper two levels up. After a successful submit, state.data is fully narrowed: string, not string | ''.

See the TypeScript section for the full exported type reference and tsconfig requirements.

Built for React 19

FormState is built around React 19's form model, not bolted on top of it. The Form component returned by the hook accepts an async action prop directly — no onSubmit wiring, no e.preventDefault(). Because it uses a real <form action>, React 19's useFormStatus works in any child component without any extra setup, and server actions drop in without adapters. This deep integration means:

  • The browser's native form submission behavior is fully preserved. See Form Submission, Next.js and React Router 7.
  • Server Actions drop in seamlessly with no adapters or wrappers required. See Next.js and React Router 7.
  • 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. It allows to render form components in the background.1
  • Predictable render cycle, even for uncontrolled components. useWatch hook is implemented with useSyncExternalStore.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. Variables returned from the useWatch hook should only be used in the render function, not as other hook dependencies.

Schema-Driven, Non-Intrusive

One Zod schema is the single source of truth for field types, validation rules, error messages, allowed values, ranges, and human-readable labels. It lives outside your components — controls are plain HTML inputs that never import or reference the schema directly.

This keeps component signatures simple and the schema portable. You can reuse the same schema for server-side API validation, share it across multiple forms, or version it independently of your UI. The schema is expressive — cross-field rules, async validators, conditional requirements — but that complexity stays in one place rather than being distributed across input components.

Controlled is Cool

Every field is controlled by default: formState.data is the source of truth and React renders from it. This makes the form fully predictable — any state transition is visible as a plain object diff, which means conditional logic, cross-field validation, and debugging are all just JavaScript.

Uncontrolled inputs are available as an escape hatch for high-frequency fields where re-rendering on every keystroke is undesirable. The value is read from the DOM at submit time and fires no intermediate state updates:

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.

Immutable form state

Every formState update produces a new object reference — no in-place mutation, no shared mutable containers. This is what makes FormState safe for the full range of React patterns: useMemo, useEffect dependency arrays, React.memo, and the React Compiler all rely on reference equality to detect changes. Libraries that store form state in a mutable ref or an external store — including React Hook Form and TanStack Form — bypass React's change-detection entirely, which means those optimisations silently stop working for form-derived values.

The getState method is used to read form data in components or utility modules that only have access to the disconnected copy of the form data and the underlying Zod schema.

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

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

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 — internal fields stripped, optional empties removed.

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';

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

// In your component:
formHooks.useListener(listener);

DeepPartial<T>

A recursive Partial — every property at every level of nesting becomes optional. The initialData option accepts DeepPartial<T>, so you can seed the form from an API response or a saved draft without providing every field.

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,
});
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 fast-equals. 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.
errorstringCustom 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.

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.
errorstringCustom 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.
errorstringCustom 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 input is parsed according to dateFormat; invalid strings produce a validation error rather than crashing. An empty string represents a blank date input.

OptionTypeDefaultDescription
requiredbooleanfalseMakes an empty string invalid.
errorstringCustom 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.
dateFormatErrorstringError 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.
errorstringError 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.
minLengthnumberMinimum number of items. Exposed via formState.ranges.
maxLengthnumberMaximum number of items. Exposed via formState.ranges.
errorstringError message for array type validation.
lengthErrorstringError 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: T) => booleanReturns true when the value is valid.
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).
errorstringCustom 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' }
    )
  );

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) => unknownMap each item to a comparison value before checking uniqueness. Use to compare a specific property rather than the whole object.
errorstringCustom 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()) as raw strings for input[pattern]
formState.patterns.name                             // '^[\\w\\s\'-]+$'
formState.patterns.get((path) => path.info.id)

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

Preparing data for an API

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

typescript
const payload = schema.toObject(formState.data);
// uuid symbols stripped, optional empty strings omitted
await api.post('/users', payload);
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.
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(…)Wire the pattern attribute for screen-reader hints
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.

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

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

// In JSX
<input
  type="text"
  value={formState.data.id}
  pattern={formState.patterns.id}
  title="Must be a number or number range, e.g. 10-20"
  onChange={(e) => formActions.change('id', e.target.value)}
/>
Note

When a field has multiple z.regex() checks, only the first one is stored in patterns. The title attribute is a good place to describe the full constraint in plain language.

touched

formState.touched tracks whether a user has interacted with a field. Its primary job is to gate error display — you almost always want to show an error only after a field has been touched, not the moment the form mounts.

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 with optional validation
formActions.touch('name', { validate: true });

// Inspect which fields have been touched
formState.touched.getKeys()                      // ['name', 'info.age', ...]
formStatus.touched                               // true if any field is 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].

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: '' }], { validate: true });

// Insert at a specific position
formActions.array.insert('tags', 0, 'featured');

// Update item at index
formActions.array.update('tags', 1, 'updated-tag');

// Swap two items
formActions.array.swap('tags', 0, 2);

// Remove by index
formActions.array.remove('tags', 0);

// Remove by predicate
formActions.array.remove('tags', (tag) => tag === 'deprecated');
formActions.array.remove((path) => path.notes, (note) => note.text === '');

// Clear the array
formActions.array.clear('tags');

Rendering an array field

Use z.symbol() inside array items to get a stable, unique key for each element without maintaining a separate ID array.

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 call by default. You control when errors surface to users through the touched pattern: only show an error once the field has been touched.

Validation options

OptionDefaultDescription
validateOnMountfalseRun validation immediately when the hook mounts.
validateOnChangetrueRe-validate after every change call.
validateOnTouchfalseRun 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.

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

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 — dispatches a reset action and re-syncs uncontrolled inputsMust wire onReset={formHandlers.handleReset} manually
React 19 post-submit reset prevention 1Includes <FormResetBlocker> automatically — form fields are not cleared after a form action specified in the action prop completesMust place <FormResetBlocker> inside the <form> manually to support form actions
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 supportListens to input/change events to feed the internal watch storeformHooks.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. Certain versions of React and Next.js reset uncontrolled form controls after a server action completes. Because FormState manages all field values internally, this reset is never desirable — the fields would momentarily revert to their HTML defaults before FormState can restore them. <FormResetBlocker> intercepts and cancels that reset at the DOM level.
  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;
};
Features

Manual Errors

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

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.startsWith('_'),
});

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__error form-state__touched form-state__required"

<div className={formClasses('name')}>
  <label>Name</label>
  <input ... />
  <span>{formState.errors.name}</span>
</div>

// Arrow function path expression — works for nested fields too
<label className={formClasses((path) => path.info.name)}>
  {formState.descriptions.get((path) => path.info.name)}
</label>

// Custom prefix per-call
formClasses('name', undefined, { prefix: 'my-form' })
// "my-form__error my-form__required"

CSS class names

ClassApplied when
[prefix]__errorField has a validation error.
[prefix]__touchedField has been touched.
[prefix]__requiredField is required per schema.

Override the prefix globally:

typescript
useFormState(schema, { CSSPrefix: 'my-form' });
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,
  confirmDirtyStateNavigation: true,  // warn on unsaved navigation
  watch: true,                        // enable useWatch
})(AppForm);
Warning

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

Features

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

Selectors

formHooks.useSelector is a hook that derives a memoized value from form state. Pass an array of input selectors and a result function — the result is only recomputed when one of the inputs changes by reference. The returned selector function is stable across renders and can be called with any state snapshot.

Simple selectors

A selector with a single input extracts or transforms one slice of state. The state type is inferred automatically from the schema — no manual annotation needed.

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

// Extract a field as-is
const selectName = formHooks.useSelector(
  state => state.name,
  name => name
);

// Derive a boolean from a field
const selectIsAdult = formHooks.useSelector(
  state => state.age,
  age => typeof age === 'number' && age >= 18
);

selectName(formState.data)     // string
selectIsAdult(formState.data)  // 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.

Advanced

Debounced Changes

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

typescript
formActions.change('search', inputValue, {
  debounceIntervalMs: 400,
  callback: (state, status) => {
    // Fires 400ms after the last keystroke
    performSearch(state.data.search);
  },
});

// Arrow function path — nested field
formActions.change((path) => path.notes[index].text, e.target.value, {
  debounceIntervalMs: 500,
  touch: true,
  callback: (state: FormState<FormSchema>) => {
    // Custom async check after user stops typing
    if (state.data.notes[index].text === '666') {
      formActions.setError((path) => path.notes[index].text, 'Not allowed');
    } else {
      formActions.setError((path) => path.notes[index].text);
    }
  },
});

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

Advanced

State Listener

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

Warning

The listener must be a stable reference — define it outside the component or wrap it in useCallback.

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'What triggered the event.
dataTCurrent form data snapshot.
formDataFormData?Native FormData — only on submit events.
submitCountnumberHow many times the form has been submitted.
errorsRecord<string, string>Current error map.
Advanced

useWatch

formHooks.useWatch(name, compute?) observes the raw DOM value of a named input as the user types — without needing a controlled value prop. Requires watch: true in the hook options.

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.

Advanced

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
});
Advanced

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';

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

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

// Date parsing with format string
convert.toDate('12/31/2024', { format: 'MM/dd/yyyy' })  // Date object

// Optional boolean to boolean type conversion
convert.asBoolean(true);      // true
convert.asBoolean('', false); // false
convert.asBoolean('', true);  // true

// Boolean coercion
convert.toBoolean('true')     // true
convert.toBoolean('0')        // false

// Validated literal — returns the value or '' if not in set
convert.toLiteral('active', ['active', 'inactive'])  // 'active'
convert.toLiteral('other',  ['active', 'inactive'])  // ''

Date formatting

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, { validate: true });
} else {
  // result.errors.getAll() — array of validation messages
  // result.errors.getKeys() — failed field paths
  console.warn('API data has validation issues:', result.errors.getAll().join(' | '));
  // Still use the coerced data if errors are non-critical
  formActions.replace(result.data);
}
Advanced

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(formData: z.infer<typeof schema>): Promise<void> {
  const result = parseState(schema, formData, true);

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

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

Form component

tsx
"use client";

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

const schema = z.object({
  name: z.formString({ required: true, error: "Name is required" }),
  email: z.formString(
    { required: true, error: "Email is required" },
    z.regex(z.regexes.email, "Enter a valid email"),
  ),
  message: z.formString(
    { required: true, error: "Message is required" },
    z.minLength(10, "Must be at least 10 characters"),
  ),
  agreeToTerms: z.formBoolean({ required: true, error: "Agreement is required" })
    .check(z.validate((v) => v === true, "You must agree to continue")),
});

export default function ContactForm() {
  const {
    formState: { data, errors },
    formStatus: { dirty, submitting },
    formActions,
    formHandlers,
    Form,
  } = useFormState(schema);

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

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

  return (
    <Form action={handleSubmit}>
      {errors.getManual("_serverError") && (
        <p className="error">{errors.getManual("_serverError")}</p>
      )}

      <label>
        Name
        <input
          type="text"
          name={formActions.inferName("name")}
          defaultValue={data.name}
          onBlur={(e) => formActions.change("name", e.target.value, { touch: true })}
        />
        {errors.name && <span>{errors.name}</span>}
      </label>

      <label>
        Email
        <input
          type="email"
          name={formActions.inferName("email")}
          defaultValue={data.email}
          onBlur={(e) => formActions.change("email", e.target.value, { touch: true })}
        />
        {errors.email && <span>{errors.email}</span>}
      </label>

      <label>
        Message
        <textarea
          name={formActions.inferName("message")}
          defaultValue={data.message}
          onBlur={(e) => formActions.change("message", e.target.value, { touch: true })}
        />
        {errors.message && <span>{errors.message}</span>}
      </label>

      <label>
        <input
          type="checkbox"
          name={formActions.inferName("agreeToTerms")}
          defaultChecked={data.agreeToTerms === true}
          onChange={(e) => formActions.change("agreeToTerms", e.target.checked, { touch: true })}
        />
        I agree to the terms
        {errors.agreeToTerms && <span>{errors.agreeToTerms}</span>}
      </label>

      <button type="submit" disabled={submitting}>
        {submitting ? "Sending…" : "Send"}
      </button>

      <button type="reset" disabled={!dirty}>
        Reset
      </button>
    </Form>
  );
}
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 TypeError("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";

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

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

    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.

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 Response("Unexpected user data shape", { status: 500 });
  }

  return result.data;
}

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

Update ContactForm to accept and forward the prop:

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
}
Advanced

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 DATE_FORMAT = 'MM/dd/yyyy';

const profileSchema = z.strictObject({
  name:        z.formString({ required: true }).with(z.describe('Name')),
  email:       z.formString({ required: true }).with(z.describe('Email')),
  dateOfBirth: z.formDate({ dateFormat: DATE_FORMAT }).with(z.describe('Date of Birth')),
  bio:         z.formString().with(z.describe('Bio')),
});

// Inferred form state shape TypeScript type
type ProfileData = z.infer<typeof profileSchema>;

// Derived API type — empty literals replaced with `undefined`, optional fields use `?`
type ProfileApiData = SchemaDataObject<ProfileData>;

// Your fetch layer — swap for any HTTP client
declare const api: {
  getProfile(userId: string): Promise<ProfileApiData>;
  updateProfile(userId: string, values: ProfileApiData): Promise<void>;
};

function FormError({ error }: { error?: string }) {
  return error ? <span className="field-error">{error}</span> : null;
}

function ProfileForm({ userId }: { userId: string }) {
  const queryClient = useQueryClient();

  // 1. Fetch and validate current server data
  const { data } = useSuspenseQuery({
    queryKey: ['profile', userId],
    queryFn: async () => {
      const apiData = await api.getProfile(userId);

      // Validate the API response against the schema.
      const result = parseState(profileSchema, apiData);

      if (result.success) {
        return result.data;
      } else {
        throw new Error(result.errors.getAll().join(' | '));
      }
    },
  });

  // 2. Seed the form — confirmDirtyStateNavigation prompts before unsaved navigation
  const { Form, formState, formStatus, formActions, formHandlers } =
    useFormState(profileSchema, {
      initialData: data,
      confirmDirtyStateNavigation: true,
    });

  // 3. Mutation for saving
  const { mutateAsync } = useMutation({
    mutationFn: (values: ProfileApiData) => api.updateProfile(userId, values),
  });

  // 4. Submit handler — mutateAsync lives inside the async callback
  const handleSubmit = formHandlers.handleSubmit(
    async (submitState: SubmitState<typeof profileSchema>) => {
      if (!submitState.valid) {
        return submitState.errors;
      }

      try {
        await mutateAsync(profileSchema.toObject(submitState.data));
        return true;
      } catch (err) {
        return { _serverError: err instanceof Error ? err.message : 'Save failed.' };
      }
    },
    {
      onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: ['profile', userId] });
      },
    }
  );

  return (
    <Form action={handleSubmit}>
      <FormError error={formState.errors.getManual('_serverError')} />

      <label>
        {formState.descriptions.name}
        {formState.required.name && <span> *</span>}
        <input
          value={formState.data.name}
          required={formState.required.name}
          onChange={(e) => formActions.change('name', e.target.value)}
          onBlur={() => formActions.touch('name')}
        />
        <FormError error={formState.errors.name} />
      </label>

      <label>
        {formState.descriptions.email}
        {formState.required.email && <span> *</span>}
        <input
          type="email"
          value={formState.data.email}
          required={formState.required.email}
          onChange={(e) => formActions.change('email', e.target.value)}
          onBlur={() => formActions.touch('email')}
        />
        <FormError error={formState.errors.email} />
      </label>

      <label>
        {formState.descriptions.dateOfBirth}
        <input
          type="text"
          placeholder={DATE_FORMAT}
          value={formState.data.dateOfBirth}
          onChange={(e) => formActions.change('dateOfBirth', e.target.value)}
          onBlur={() => formActions.touch('dateOfBirth')}
        />
        <FormError error={formState.errors.dateOfBirth} />
      </label>

      <label>
        {formState.descriptions.bio}
        <textarea
          value={formState.data.bio ?? ''}
          onChange={(e) => formActions.change('bio', e.target.value)}
          onBlur={() => formActions.touch('bio')}
        />
      </label>

      <button type="submit" disabled={formStatus.submitting}>Save Changes</button>
    </Form>
  );
}

// Wrap at the call site — Suspense handles loading, ErrorBoundary handles fetch errors
function ProfilePage({ userId }: { userId: string }) {
  return (
    <ErrorBoundary fallback={<ErrorMessage />}>
      <Suspense fallback={<Skeleton />}>
        <ProfileForm userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

Seeding initialData from a query

With useSuspenseQuery, data is always defined — the component suspends until the query resolves, so useFormState always receives the full server value on first render.

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.

Advanced

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.

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

For Vite projects — especially Remix with SSR — NODE_ENV may not be set to development automatically. Without it, FormDock may render on the server and break SSR. Pass devMode={import.meta.env.DEV} to be explicit:

tsx
<FormDock form={form} devMode={import.meta.env.DEV} />

What it looks like

The dock sits at the bottom of the viewport as a collapsible bar. The header shows a validation indicator — orange when unvalidated, red when invalid, hidden when valid. Clicking the header toggles between three sizes.

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

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
formFormStateResponseThe 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)
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} />
Tip

FormDock saves the last-used panel size to sessionStorage so it persists across hot reloads. The collapsed prop is only used when there is no saved size.

Production safety

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


Reference

Comparison

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

FeatureFormStateTanStack FormReact Hook FormFormikFinal Form
Bundle size (gzipped)~15 KB 1~13 KB~10 KB~15 KB~9 KB
First-class TypeScript
Fully inferred TypeScript🛑
Typed lambda path expressions🛑🛑🛑🛑
No field wrappers required🛑🛑 2🛑🛑
No render props or abstractions🛑🛑🛑🛑
Granular field-level re-renders3🛑
Change and submit event subscriptions🛑
React 19 actions / server actions🛑🛑🛑
React 19 built-in form compatibility🛑🛑🛑🛑
FormData support🛑🛑🛑🛑
React Compiler support🛑
SSR integrations🛑🛑🛑
Built-in async validation debounce🛑🛑🛑
Schema-based validation🛑
Schema-derived field constraints🛑🛑🛑🛑
Cross-field validation
Array fields
Initial data reactivity4🛑🛑🛑🛑
Separate form and API type representations🛑🛑🛑🛑
Built-in readonly / disabled mode🛑🛑🛑🛑
Password value not stored in DOM5🛑🛑🛑🛑
Unsaved changes navigation prompt6🛑🛑🛑🛑
ESLint plugin78🛑🛑
First-party DevTools🛑
  1. FormState requires Zod Mini (~8-14 KB gzipped) and fast-equals (~2 KB gzipped). Other libraries are often coupled with schema libraries: TanStack Form and React Hook Form support Zod, Yup, Valibot, and others via adapters; Formik pairs primarily with Yup; Final Form has no built-in schema adapter but is more limited in validation capabilities.
  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. useWatch isolates keystroke re-renders to subscribers by observing the DOM element's value directly, independent of React state. For high-frequency inputs where even that is undesirable, use uncontrolled inputs instead.
  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. Opt-in via confirmDirtyStateNavigation: true. Prompts the user before navigating away when the form has unsaved changes.
  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.

Ref-Based Form Library Issues

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()
key prop change breaks field state✅ no registration to reset🛑 remount triggers re-registration and clears state🛑 remount drops the ref and unregisters the field
Array field identity desyncs from state✅ immutable operations, no generated IDs — use a Symbol field if item identity is needed❓ safe if using built-in methods; direct mutation desyncs🛑 useFieldArray tracks by generated ID; outside mutation desyncs
ref conflicts on custom inputs✅ no ref used for trackingform.Field does not rely on DOM refs🛑 register() owns the ref; manual merge required