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 callsonChangeโgetSnapshotto see whether to re-render.getSnapshot()must return the same value (byObject.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.
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
- 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.
- Notify per path. A
Map<path, Set<listener>>letsset("email")wake only email subscribers plus any โallโ subscribers. - Return stable snapshots. A fieldโs snapshot is its value; primitives compare by value, objects by reference. Never build a new object in
getSnapshot. - Memoise
subscribeandgetSnapshot. New function identities on every render make React resubscribe each time;useCallbackkeyed on store and path avoids it. - Derive form-wide state as primitives.
isDirty,errorCountandcanSubmitsubscribe to all changes but return booleans or numbers, so they render only when the answer changes. - Provide a server snapshot. Passing
getSnapshotas 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.
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.
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.