Holding a large formโ€™s values in useState at the top means every keystroke re-renders the whole tree; moving them into a store outside React and letting each field subscribe to only its own value turns that into a single-field re-render โ€” and useSyncExternalStore is the hook React provides to do it without tearing under concurrent rendering.

The React form hook architecture describes field hooks and adapters. This page builds the storage layer beneath them: a framework-agnostic store with path-level subscriptions, and a thin React binding. It is the same design most performant form libraries use internally, and building a small one clarifies what those libraries are doing.


Context and prerequisites

useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?) has a precise contract:

  • subscribe(onChange) registers a callback and returns an unsubscribe function. React calls onChange โ†’ getSnapshot to see whether to re-render.
  • getSnapshot() must return the same value (by Object.is) when nothing relevant changed. Returning a fresh object each call causes an infinite render loop.
  • getServerSnapshot() provides the value during server rendering and hydration.

The performance comes from two decisions: subscriptions are per path, so a change to email only notifies subscribers of email; and snapshots are primitive or stable references, so unaffected fields bail out without rendering.

Who re-renders when email changes With all values in top-level useState, changing the email re-renders the form component and all 120 field components, unless each is memoised with stable props. With a store and path subscriptions, changing email notifies only the email field's subscription and any subscribers to derived form-wide values such as isDirty; the other 119 fields do not render. Top-level useState Form re-renders. All 120 fields re-render unless memoised with stable props. Store + path subscriptions Only the email field renders. Plus subscribers to derived values (isDirty). 119 fields untouched.

The core pattern: a path-subscribed store and a stable hook

type Listener = () => void;

export function createFormStore<T extends Record<string, unknown>>(initial: T) {
  let values = initial;
  const pathListeners = new Map<string, Set<Listener>>();
  const anyListeners = new Set<Listener>();

  const notify = (path: string) => {
    pathListeners.get(path)?.forEach((l) => l());
    anyListeners.forEach((l) => l());
  };

  return {
    get: <K extends keyof T>(path: K) => values[path],
    getAll: () => values,                       // stable reference until something changes
    set<K extends keyof T>(path: K, value: T[K]) {
      if (Object.is(values[path], value)) return;          // no-op writes notify nobody
      values = { ...values, [path]: value };               // new top-level ref for getAll()
      notify(path as string);
    },
    subscribePath(path: keyof T, l: Listener) {
      const key = path as string;
      if (!pathListeners.has(key)) pathListeners.set(key, new Set());
      pathListeners.get(key)!.add(l);
      return () => { pathListeners.get(key)!.delete(l); };
    },
    subscribeAll(l: Listener) {
      anyListeners.add(l);
      return () => { anyListeners.delete(l); };
    },
  };
}
export type FormStore<T extends Record<string, unknown>> = ReturnType<typeof createFormStore<T>>;
import { useCallback, useSyncExternalStore } from "react";

// One field subscribes to exactly one path. The snapshot is the field's own
// value โ€” a primitive for text inputs โ€” so unrelated changes never render it.
export function useFieldValue<T extends Record<string, unknown>, K extends keyof T>(store: FormStore<T>, path: K) {
  const subscribe = useCallback((l: () => void) => store.subscribePath(path, l), [store, path]);
  const getSnapshot = useCallback(() => store.get(path), [store, path]);
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

// Derived, form-wide values subscribe to everything but return a primitive,
// so they only re-render when the derived answer actually flips.
export function useIsDirty<T extends Record<string, unknown>>(store: FormStore<T>, baseline: T) {
  const getSnapshot = useCallback(
    () => Object.keys(baseline).some((k) => !Object.is(store.get(k as keyof T), baseline[k])),
    [store, baseline],
  );
  return useSyncExternalStore(store.subscribeAll, getSnapshot, getSnapshot);
}

export function TextField({ store, name, label }: { store: FormStore<any>; name: string; label: string }) {
  const value = useFieldValue(store, name) as string;
  return (
    <>
      <label htmlFor={name}>{label}</label>
      <input id={name} name={name} value={value} onChange={(e) => store.set(name, e.target.value)} />
    </>
  );
}

Step-by-step walkthrough

  1. Keep values outside React. The store is a plain object with methods; it can be created per form instance and passed down via props or a context that holds only the (stable) store reference, never values โ€” the separation described in splitting form context to stop cascading re-renders.
  2. Notify per path. A Map<path, Set<listener>> lets set("email") wake only email subscribers plus any โ€œallโ€ subscribers.
  3. Return stable snapshots. A fieldโ€™s snapshot is its value; primitives compare by value, objects by reference. Never build a new object in getSnapshot.
  4. Memoise subscribe and getSnapshot. New function identities on every render make React resubscribe each time; useCallback keyed on store and path avoids it.
  5. Derive form-wide state as primitives. isDirty, errorCount and canSubmit subscribe to all changes but return booleans or numbers, so they render only when the answer changes.
  6. Provide a server snapshot. Passing getSnapshot as the third argument works when the store is created with the same initial values on server and client; otherwise hydration mismatches, as in preventing hydration mismatch in Next.js forms.
One keystroke through the store The email input's change handler calls store.set with the new value. The store replaces its values object and notifies listeners registered for the email path and listeners registered for all changes. React calls getSnapshot for each notified subscription. The email field's snapshot changed, so it re-renders. The isDirty hook's snapshot is recomputed but is still true, so it does not re-render. No other field was notified at all. onChange โ†’ store.set("email", v) No-op writes return early. Object.is guard skips identical values. notify("email") email listeners + all-listeners. Other paths' listeners are not called. getSnapshot per subscription Compared with Object.is. isDirty: still true โ†’ no render. Email field re-renders Only this component. The other fields never learned anything happened.

Adding errors and flags to the same store

Values are only one of the things fields read. Errors, touched flags and pending-async markers follow the same rule: store each under its own path (errors.email, touched.email) and let the field subscribe to the three paths it needs. A field component then renders when its value, its error or its touched flag changes โ€” and never when a neighbourโ€™s does. Keeping these in the same store rather than in separate React state means validators, the error summary and the submit gate all read one consistent snapshot, which removes a class of bugs where the summary shows an error that the field has already cleared.


Failure modes and edge cases

1. Infinite loop from an unstable snapshot

getSnapshot: () => ({ value: store.get(path) }) returns a new object every call; React sees a change every time and re-renders forever (React warns โ€œThe result of getSnapshot should be cachedโ€). Return the value itself, or cache derived objects by input.

2. Nested paths

A flat Record<string, unknown> with dotted keys ("address.city") is the simplest way to support nested fields with path subscriptions. If you store nested objects, set must copy each level on the way down, and path subscribers for parents (address) must be notified when a child changes.

3. Mutating the values object

Assigning values[path] = v in place keeps getAll() returning the same reference, so subscribers to the whole form never see a change. Always replace the top-level object on write.

4. Validation inside set

Running a schema on every set makes each keystroke as slow as validation. Store errors in the same store under their own paths, and run validators on the timing described in reward early, punish late, writing results with set.

5. Concurrent rendering and transitions

useSyncExternalStore forces synchronous rendering when the store changes during a transition, which is what prevents tearing (two components showing different versions of the same value). It also means store updates cannot be deferred with startTransition; keep expensive derived UI behind useDeferredValue on the snapshot instead.

Snapshot choices and their re-render behaviour Returning the field's primitive value is stable and re-renders only when the value changes. Returning the whole values object is stable between writes but re-renders on every change to any field. Returning a new object literal built in getSnapshot is unstable and causes an infinite render loop. Returning a derived boolean such as isDirty is stable and re-renders only when the boolean flips. getSnapshot returns Stable? Re-renders when the field's primitive value yes that value changes store.getAll() between writes any field changes { value } built each call no forever (loop) derived boolean (isDirty) yes the answer flips

Verification checklist


Frequently Asked Questions

How is this different from putting the store in React context?

Context re-renders every consumer when its value changes. Putting the store object in context is fine because that reference never changes; putting values in context is what causes cascades. Components get the store from context and subscribe to their slice with useSyncExternalStore.

Should I use Zustand or Jotai instead?

Both are built on the same hook and give you selectors and atoms with less code. A hand-rolled store is worth it when you want form-specific semantics โ€” path subscriptions, dirty baselines, error slots โ€” without adapting a general state library. Either way, the rule about stable snapshots applies.

Does this help with uncontrolled inputs?

Uncontrolled inputs already avoid re-renders for their values. A store adds value when other components need to react to field values โ€” derived totals, conditional fields, dirty flags โ€” without re-rendering the fields themselves.


Related

โ† React Form Hook Architecture