The exact problem: a custom useFormField hook re-renders every field in a large form on each keystroke, because all fields subscribe to one shared value object whose reference changes on every edit.
Context and Prerequisites
This page assumes you already have the hook from building a custom useFormField hook and now need to make it fast at 50-plus fields. The parent React form hook architecture covers the reducer and subscription model; here we cut the wasted renders that model can leak when every field reads the same state object.
The Re-Render Storm
The root cause is almost always a single shared object. Whether it lives in useState at the form root or in a Context value, any edit replaces the object reference, and React re-renders every component that reads it — even the 99 fields that did not change.
// ANTI-PATTERN: one context value; every field consumer re-renders on any edit.
const FormContext = createContext<{ values: Values; setValue: Fn } | null>(null);
function Field({ name }: { name: string }) {
const ctx = useContext(FormContext)!; // subscribes to the WHOLE value
// Editing any other field changes ctx.values, re-rendering this component.
return <input value={ctx.values[name]} onChange={e => ctx.setValue(name, e.target.value)} />;
}
The fix is to stop broadcasting the whole object. Keep form state in an external store and let each field subscribe to only its own slice through useSyncExternalStore with a selector. React then re-renders a field only when that field’s slice actually changes.
Core Pattern: Store + Selector Subscription
import { useSyncExternalStore, useCallback, useRef } from 'react';
interface FieldSlice {
value: string;
error: string | null;
touched: boolean;
}
// A minimal external store. State lives OUTSIDE React so a change to one
// field never forces React to re-render unrelated subscribers.
export class FormStore {
private state = new Map<string, FieldSlice>();
private listeners = new Set<() => void>();
// Per-field cached snapshots: getSnapshot must return a referentially stable
// object when nothing changed, or useSyncExternalStore re-renders forever.
private snapshots = new Map<string, FieldSlice>();
subscribe = (cb: () => void): (() => void) => {
this.listeners.add(cb);
// The returned unsubscribe MUST be called on unmount; useSyncExternalStore
// calls it for us, but only if subscribe is referentially stable (it is,
// as a bound class method) — an inline arrow would resubscribe each render.
return () => this.listeners.delete(cb);
};
getField = (name: string): FieldSlice => {
const current = this.state.get(name) ?? { value: '', error: null, touched: false };
const cached = this.snapshots.get(name);
// Return the cached reference if the slice is structurally identical, so
// Object.is in useSyncExternalStore succeeds and the render is skipped.
if (cached &&
cached.value === current.value &&
cached.error === current.error &&
cached.touched === current.touched) {
return cached;
}
this.snapshots.set(name, current);
return current;
};
setValue(name: string, value: string): void {
const prev = this.state.get(name) ?? { value: '', error: null, touched: true };
this.state.set(name, { ...prev, value, touched: true });
// Notify only wakes subscribers; each one's selector decides whether to render.
this.listeners.forEach(l => l());
}
}
/**
* Field hook: subscribes to ONE field's slice, returns stable callbacks.
* Only the edited field re-renders; siblings stay untouched.
*/
export function useFormField(store: FormStore, name: string) {
// useSyncExternalStore bails out of the render when getSnapshot returns a
// value that is Object.is-equal to the previous one — which the store's
// cached snapshot guarantees for unchanged fields.
const slice = useSyncExternalStore(
store.subscribe,
useCallback(() => store.getField(name), [store, name]),
);
// Stable callback: identity never changes across renders, so a memoized
// input child does not re-render because its onChange prop changed.
const onChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => store.setValue(name, e.target.value),
[store, name],
);
return { ...slice, onChange };
}
Step-by-Step Walkthrough
-
Move state out of React. The
FormStoreholds every field slice in aMap. Because the state is external, mutating one field does not create a new React-owned object that forces consumers to re-render. -
Cache per-field snapshots.
getFieldreturns the same object reference when a field’s slice is unchanged.useSyncExternalStorecompares snapshots withObject.is; without the cache,getFieldwould return a fresh object every call,Object.iswould always fail, and the hook would render on every store notification — the exact storm you are trying to kill. -
Subscribe per field with a scoped selector. Each
useFormFieldcall reads only its own slice. WhensetValue('email', …)notifies, every subscriber’sgetSnapshotruns, but only the email field’s snapshot differs, so only that field re-renders. -
Return stable callbacks.
onChangeis wrapped inuseCallbackwith[store, name]deps, so its identity is stable across renders. A memoized<input>child then does not re-render merely because itsonChangeprop got a new reference. -
Hold transient values in a ref. For values that change faster than the UI needs to repaint — an IME composition buffer, a debounce timer handle — keep them in
useRefand promote to the store only on blur or after a debounce, as shown below.
Transient values in a ref
export function useDebouncedField(store: FormStore, name: string, ms = 200) {
const { value, error, onChange } = useFormField(store, name);
// Ref holds the pending timer; reading/writing it never schedules a render.
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Ref mirrors the latest keystroke so the debounced write reads fresh input
// without the component re-rendering on every character.
const latest = useRef(value);
const onChangeDebounced = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
latest.current = e.target.value; // transient — no render
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
onChange({ target: { value: latest.current } } as React.ChangeEvent<HTMLInputElement>);
}, ms);
},
[onChange, ms],
);
return { value, error, onChange: onChangeDebounced };
}
This is the read-side complement to debouncing validation triggers in React: the ref keeps intermediate keystrokes out of render, and only the settled value reaches the store and any validation it triggers.
Failure Modes and Edge Cases
1. Selector returns a fresh object every call
Returning { value, error } inline from getSnapshot fails Object.is on every notification and renders infinitely — React even throws a “getSnapshot should be cached” warning.
// WRONG: new object each call -> infinite re-render.
useSyncExternalStore(sub, () => ({ value: store.get(name) }));
// RIGHT: return a primitive, or a cached object reference (see getField above).
useSyncExternalStore(sub, () => store.getField(name));
2. Inline subscribe function resubscribes every render
Passing an inline arrow as the subscribe argument gives it a new identity each render, so useSyncExternalStore tears down and re-adds the listener constantly.
// WRONG: new subscribe identity each render.
useSyncExternalStore(cb => store.listeners.add(cb) && (() => {}), snap);
// RIGHT: a stable bound method (store.subscribe) added once.
useSyncExternalStore(store.subscribe, snap);
3. Context still wraps the store value itself
If you put the store instance in Context that is fine — the instance is stable. But if you also put the current values in the same Context value object, you reintroduce the storm. Only the stable store reference belongs in Context.
4. Memoized input still re-renders from an unstable onChange
React.memo on a field input is defeated if onChange gets a new identity each render. Confirm every callback the input receives is useCallback-wrapped with correct deps.
5. useRef value read during render is stale
A ref does not trigger a render, so reading latest.current during render can show a value one keystroke behind. Read refs in event handlers and effects, never as the source of rendered output.
Verification Checklist
FAQ
Why does typing in one field re-render every other field in my form?
You are almost certainly holding the whole form value in a single Context or a single useState at the top of the tree. Any change to that object produces a new reference, and every consumer of the context re-renders regardless of which field changed. Move state into an external store and subscribe each field to only its own slice with useSyncExternalStore and a selector. React then compares the selected slice with Object.is and skips the render for fields whose slice did not change.
Does useSyncExternalStore bail out of a render if the selected slice is unchanged?
Yes, if the selector returns a referentially stable value. useSyncExternalStore compares the previous and next snapshot with Object.is and skips the render when they match. The trap is returning a fresh object or array from the selector on every call — that always fails Object.is and re-renders forever, which React warns about with “getSnapshot should be cached.” Return a primitive, or memoize the derived object with a cached getSnapshot that reuses the previous reference when the underlying data is structurally identical.
When should a transient value live in useRef instead of useState?
Use a ref when the value changes rapidly but the UI does not need to repaint on every change — an in-progress keystroke buffer, the last-focused element, a debounce timer handle. Reading or writing a ref never schedules a render, so intermediate values stay out of the render path entirely. Promote the value to state or the store only at the moments the UI must reflect it, such as on blur or after a debounce interval. Never read a ref as the source of rendered output, because it will be one update behind.
Related
- Building a Custom useFormField Hook — the base hook this page tunes
- React Form Hook Architecture — the reducer and subscription model behind the field hook
- Debouncing Validation Triggers in React — pair with the ref buffer to keep keystrokes out of render