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

  1. Move state out of React. The FormStore holds every field slice in a Map. Because the state is external, mutating one field does not create a new React-owned object that forces consumers to re-render.

  2. Cache per-field snapshots. getField returns the same object reference when a field’s slice is unchanged. useSyncExternalStore compares snapshots with Object.is; without the cache, getField would return a fresh object every call, Object.is would always fail, and the hook would render on every store notification — the exact storm you are trying to kill.

  3. Subscribe per field with a scoped selector. Each useFormField call reads only its own slice. When setValue('email', …) notifies, every subscriber’s getSnapshot runs, but only the email field’s snapshot differs, so only that field re-renders.

  4. Return stable callbacks. onChange is wrapped in useCallback with [store, name] deps, so its identity is stable across renders. A memoized <input> child then does not re-render merely because its onChange prop got a new reference.

  5. 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 useRef and 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.

Before tuning anything, it is worth knowing which of the three broadcast topologies you actually have, because the fix differs for each:

Three topologies, three very different keystroke costs One context value holding all the values: every consumer re-renders on every edit, because the context value object is new, giving sixty renders per keystroke on a sixty-field form. Prop drilling from a parent that holds the state: the parent re-renders and so does every child it passes values to, giving sixty-one renders and additionally defeating memo unless every callback is stable. Per-field store subscriptions: the store notifies all sixty subscribers, fifty-nine return their cached slice and are skipped, and one re-renders. The middle row is the one people migrate to when context is slow, and it is not an improvement. Topology Renders per keystroke Why one context value 60 the value object is new every time prop drilling from a parent 61 the parent renders, then every child per-field subscriptions 1 59 cached slices compare equal, and skip The middle row is where teams often land after "context is slow" — it is slightly worse, and it also defeats memo. Identify yours in the profiler by what renders: the provider, the parent, or one field. Only the third row scales — the other two are linear in field count no matter how much memoization is added around them.

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.

One more mental model makes the whole thing click. A store notification is not a render — it is a question asked of every subscriber, and almost all of them answer “nothing changed”:

One notification, sixty questions, one render Editing the email field calls setValue, which notifies every subscriber. React then calls getSnapshot once per subscribed field — sixty calls in a sixty-field form. Fifty-nine of them return the same cached object reference they returned last time, so Object.is succeeds and React skips those components entirely. One of them, the email field, returns a new reference, and only that component renders. The lesson is that the fan-out is sixty pointer comparisons, not sixty renders, which is why the pattern scales. setValue("email") notifies every listener 59 × getSnapshot same cached reference returned skipped Object.is succeeded 1 × getSnapshot new reference — the slice changed 1 render the email field only Cost per keystroke: 60 pointer comparisons plus one render — which is why losing the snapshot cache turns it into 60 renders.

Verification Checklist

Reading the profile after each change

Tuning is only finished when the numbers say so, and the two numbers worth watching move independently.

Which number is still wrong tells you what is left to fix Many components render and each commit is short: the subscription is still broadcasting, so narrow it to per-field slices. Many components render and the commit is long: both problems are present, and the subscription must be fixed first because it multiplies the second. One component renders but the commit is long: the subscription is correct and the field component itself is expensive — look at what it renders, not at how often. One component renders and the commit is short: this is the target, and any remaining slowness is in the validator or in layout rather than in React. short commit long commit many render one renders still broadcasting narrow the subscription to per-field slices before touching anything else both problems at once fix the subscription first — it multiplies whatever the commit cost turns out to be the target anything still slow is in the validator or in layout, not in React an expensive field component the subscription is right — look at what the field renders, not how often Record both numbers before and after every change; a fix that improves one and worsens the other is common and easy to miss.

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

React Form Hook Architecture