Skip to content

Form state & lifecycle ​

This page covers the FormState shape, derived dirtiness and the diff()/dirtyFields() helpers, touched semantics, snapshot/restore for undo and rollback, the difference between reset and adoptValues, atomic patches with updateState, non-React subscriptions, the flag hooks, and sharing a form through context.

The FormState shape ​

The entire form lives in one zustand store of FormState<z.input<TSchema>>:

ts
type FormState<TValues> = Readonly<{
  values: TValues;            // current values
  initialValues: TValues;     // the baseline dirtiness compares against
  errors: ErrorMap;           // DERIVED: merged view of the two channels below
  schemaErrors: ErrorMap;     // validation-owned channel
  serverErrors: ErrorMap;     // app-owned channel (setError/setErrors)
  touched: BoolMap;           // path → interacted
  isSubmitting: boolean;
  submitCount: number;        // incremented per submit attempt
  isValidating: BoolMap;      // path → async field validation in flight
  isValidatingForm: boolean;  // whole-form async validation in flight
  mode: ValidationMode;
  reValidateMode: ValidationMode;
}>;

errors is derived from the two channels, so never write it directly; see Errors: schema & server. There is deliberately no dirty map, because dirtiness is computed, as described next.

Dirtiness is derived ​

A field is dirty while its value differs structurally from initialValues at that path. Arrays and plain objects compare deep, Dates compare by timestamp (re-picking the same date must not leave a field permanently dirty), and everything else compares by Object.is. Because it is derived rather than tracked by writers, it can't drift: arrayPush followed by arrayRemove reads clean again, and reset is clean by definition.

Read it per field (useField(...).dirty, form.getFieldState(path).dirty), form-wide or scoped to a subtree (useIsDirty(form), useIsDirty(form, "shipping"), a boolean-only subscription unlike useField), or as a PATCH-style payload:

ts
form.dirtyFields(); // minimal divergent paths, e.g. ["profile.name", "tags"]
form.diff();        // { "profile.name": "Ada", tags: ["a", "b"] }

Both compare values against initialValues and report minimal divergent paths: objects recurse to the changed leaves, arrays report their base path, and a divergent non-record root reports "". An object whose leaves all compare equal but whose key set differs ({} against { nickname: undefined }) reports the object itself, so the list always agrees with that path's dirty flag; at the root that is "" again, with diff() carrying the whole values object under it. Reverting a field to its initial value drops it from both.

Touched ​

touched is a plain path-keyed map of "the user has interacted with this field":

  • field.onBlur() (wired by the bound components and prop builders) sets it, as does form.setTouched(path, touched?) (default true).
  • A failed submit marks every errored field touched, so touched-gated error UIs show messages after the canonical first failed submit.
  • The "onTouched" validation mode reads it. See Validation.

Touched is stored rather than derived, so clearing it is part of reset's job.

Snapshot and restore ​

snapshot() captures the full state; restore(snap) puts it back:

ts
form.handleSubmit(async (data) => {
  const snap = form.snapshot();
  form.adoptValues(data);            // optimistic: data becomes the new baseline
  try {
    const saved = await api.save(data);
    form.adoptValues(saved);         // confirm with the server's response
  } catch {
    form.restore(snap);              // rollback
  }
});

restore re-derives the merged errors map from the snapshot's schemaErrors/serverErrors channels (defaulting missing channels for snapshots persisted under older shapes), so the errors-is-derived invariant holds even for hand-constructed snapshots.

It also clears the transient in-flight flags (isValidating and isValidatingForm) rather than restoring whatever the snapshot captured. In-flight state is owned by live validation passes, never by snapshots: a flag snapshotted mid-flight has no pass left to clear it, so restoring it would stick it on forever.

reset vs adoptValues ​

Two different contracts for replacing values:

reset(nextInitial?, options?)adoptValues(values)
values / initialValuesboth set to the (merged) initialboth set to values
errors (both channels)cleared, unless keepErrorscleared
touchedcleared, unless keepTouchedpreserved
submitCountzeroed, unless keepSubmitCountpreserved
isSubmittingclearedpreserved
isValidating / isValidatingFormclearedcleared (the rebase disowns in-flight passes, so their flags must not linger)
use case"start over"mid-session rebase (a save succeeded; the saved data is the new baseline)

reset's partial nextInitial is shallow-merged into the existing initial values when both are plain records, and replaces them wholesale otherwise, as with array-rooted or scalar-rooted schemas. There is no keepDirty option because dirtiness derives from values vs initial: reset makes them equal, so everything reads clean by definition.

resetField(path) is the single-field version: the value returns to its initial slice, and error/touched/validating state at the path and its descendants is cleared.

Atomic patches: updateState ​

When several slices must change in one store write (one render, one notification), use updateState:

ts
form.updateState((state) => ({
  values: { ...state.values, status: "archived" },
  touched: { ...state.touched, status: true },
}));

The patch type omits errors, since it is derived. Patch schemaErrors and serverErrors instead and the merged map is recomputed. Note that updateState is a raw patch: unlike setValue, it does not run the server-error release contract for you.

Persistence ​

The autosave recipe, promoted to a helper: persistForm(form, { key }) watches the values, debounce-writes them as JSON, and re-applies a found draft on the next visit.

ts
const drafts = persistForm(form, {
  key: "profile-draft",
  debounceMs: 300,          // default
  apply: "adopt",           // default: the draft becomes the new baseline (form reads clean)
});

// after a successful submit:
drafts.clear();             // also cancels any pending write
// on unmount:
drafts.dispose();

apply: "restore" loads the draft as edits, meaning dirty against the original initial values, instead of rebasing. apply: "manual" never auto-applies, so you call the returned load() yourself — a manual load() applies with adopt semantics (the loaded draft reads clean, a rebase rather than an edit); pick apply: "restore" when the draft should read dirty against the original initials. The handle method is named load, not restore, for exactly that reason: what it does by default is a rebase, and "restore" is the name of the opt-in setValues mode. Storage defaults to localStorage and is structural, so sessionStorage or any { getItem, setItem, removeItem } works. Every storage touch is guarded, so private-mode failures just skip persistence, corrupt drafts return false from load() instead of throwing, and (same caveat as the recipe) drafts round-trip through JSON, so they are for JSON-safe values: a Date comes back as a string. In SSR apps, call it inside an effect — or reach for usePersistForm(form, options), the React wrapper that owns the mount/dispose effect (StrictMode safe) and returns a reference-stable handle you can call clear() on after submit. See SSR & Next.js.

Drafts that outlive the schema ​

A stored draft can be older than the schema reading it. persistForm will not apply a draft whose shape conflicts with the form's: a path holding a string where the form expects an object, or an array of strings where it expects rows. No ordinary edit produces that, only a changed schema, and applying it would rebase the form onto values it cannot validate while adopt cleared the errors, so it would read clean while holding them.

The guard compares only overlapping paths, and only their kinds. It deliberately does not treat a missing key as evidence: JSON drops undefined slots, so an optional the user never filled is absent from the reference and an optional they did fill is present only in the draft. Neither means the schema changed, and rejecting on that would throw away good drafts from any form with an optional field.

That leaves renames and removals, which are indistinguishable from optionals. Set version for those:

ts
persistForm(form, { key: "checkout", version: 2 });

The version is stored with the draft and a mismatch discards it. Leave it unset and the stored format is byte-identical to before, so existing drafts survive an upgrade. One limit worth knowing: an empty initial array (tags: []) carries no information about its row shape, so a draft with the wrong row shape passes the automatic guard. version is the answer there.

Redux DevTools ​

The store is zustand underneath, so the Redux DevTools extension works with one option:

ts
const form = createForm(schema, {
  initialValues,
  devtools: "checkout", // the instance name in the extension
});

Every state write shows up inspectable and time-travelable, named per form so several forms stay distinguishable (devtools: true uses the name "formstand"). It is off by default and active only in non-production builds (process.env.NODE_ENV !== "production", matching zustand's own devtools default), so leaving the option set will not stream a shipped form's state to an end user who happens to have the extension installed. With the option set but the extension absent, the middleware is inert.

Subscriptions outside React ​

ts
form.subscribe((state, prev) => { ... });                   // every state change
form.watchValues((values, prev) => { ... });                // only when values change
form.watchValue("users.0.email", (next, prev) => { ... });  // one path's value
form.watchField("users.0.email", (snapshot) => { ... });    // value+error+touched+dirty+isValidating

All return an unsubscribe function. watchValue compares by Object.is; watchField fires when any part of the field's snapshot changes. watchValues watches the whole values object (the "s" means "all the values"; it is not a multi-path watchValue, so to watch several specific paths, register a watchValue per path). A typical use is autosave:

ts
const unsubscribe = form.watchValues((values) => scheduleAutosave(values));

For whole-values subscription in render, meaning form values driving derived rendering such as a map re-rendering from live coordinates, use the hook instead. useFormValues(form) returns the current values object typed as z.input<TSchema>, re-rendering exactly when a value changes (values are replaced immutably, so it is one reference comparison) and never on touched or error churn.

Flag hooks ​

ts
useIsDirty(form);             // any field dirty (derived from values vs initialValues)
useIsDirty(form, "shipping"); // ...scoped to a subtree (covers shipping.city etc.)
useIsValid(form);             // no errors currently in the merged map
useIsValid(form, "shipping"); // ...only errors at or under the path
useIsSubmitting(form);  // state.isSubmitting
useSubmitCount(form);   // state.submitCount

useIsValid reflects the error map, not a fresh validation

The error map is empty until validation runs, so a never-validated form reads as valid even if its initial values would fail the schema. If you gate a submit button on !useIsValid(form), pass validateOnMount: true; see Validation. Submitting always re-validates regardless, so an invalid form can't actually get through.

Sharing a form: createFormContext ​

useForm is per component instance, so calling it twice gives two independent forms. To share one form down a deep tree without prop drilling, create a typed context per form shape:

tsx
import { createFormContext, useField, useForm } from "formstand";

const { Provider, useFormContext } = createFormContext<typeof schema>();

const Parent = () => {
  const form = useForm(schema, { initialValues });
  return (
    <Provider form={form}>
      <NameField />
      <EmailField />
    </Provider>
  );
};

const NameField = () => {
  const form = useFormContext(); // typed Form<typeof schema>
  const name = useField(form, "name"); // path inference intact
  return <input {...textInputProps(name)} />;
};

The factory pattern, one createFormContext call per form shape, carries the schema's type through the context, so children keep full path inference. useFormContext throws when used outside its matching Provider. For small trees, just passing form as a prop works fine; for a single app-wide form, a module-scope createForm also works, and pairs with createFormHooks below.

Pre-wired hooks: createFormHooks ​

For a form that is a genuine module-level singleton, one instance for the app's lifetime such as settings or a global compose box, skip the provider entirely: bake the form into the hooks once and export them as a domain API.

tsx
import { createForm, createFormHooks } from "formstand";

const form = createForm(invoiceSchema, { initialValues, mode: "onBlur" });

export const {
  useInvoiceField,
  useInvoiceFieldArray,
  useInvoiceSelector,
  useInvoiceIsDirty,
} = createFormHooks(form, "invoice");

// Anywhere in the app, with no provider and no form prop:
const CustomerField = () => {
  const customer = useInvoiceField("customer"); // path inference intact
  return <input {...textInputProps(customer)} />;
};

The optional name is baked into the hook names at both the type level and runtime ("invoice" gives you useInvoiceField, useInvoiceSelector, useInvoiceIsDirty, and so on), so a typo'd destructure is a compile error. Omit it for unprefixed names (useField, useSelector, and the rest). Every returned hook keeps its unbound signature minus the form argument, including typed paths, item inference in useInvoiceFieldArray, and path-scoped flags.

A singleton is a singleton

The module-level form never unmounts: its state persists across route changes until you reset() it, and under SSR (Next.js and friends) module scope is shared across requests on the server. Keep createFormHooks forms to client-only modules, and use useForm plus createFormContext for anything with a per-mount lifecycle.

Next ​

Built on zod and zustand. Released under the MIT License.