The typical first version of a form in React puts everything in one context — { values, errors, touched, setValue, validate } — and every field consumes it; since the context value is a new object on every keystroke, every field re-renders on every keystroke, and a 150-field form starts to lag at about the speed of a fast typist.

This is the most common performance bug in the React form hook architecture, and it is structural: React.memo on the fields does not help, because context consumers re-render regardless of props. The fix is to separate what changes from what does not, and to give each field a way to hear only about its own slice.


Context and prerequisites

How context propagates: when a provider’s value changes by reference, React re-renders every component that called useContext on it — skipping memoisation — and then their children unless memoised. So a context value’s churn rate sets a floor on how much renders.

A form context typically mixes three categories:

  • Actions — setValue, blur, submit, register. Never need to change identity.
  • The store — the object holding values, errors and flags. Its reference can be stable even though its contents change.
  • Values and derived state — values, errors, isDirty. Change on every keystroke.

Putting the third category in context is the mistake. The fix is to put only the first two in context and let fields subscribe to the third through a store, as built in building a form store with useSyncExternalStore.

Three kinds of thing in a form context Actions such as setValue and submit never need to change identity and belong in a stable context. The store reference never changes for the life of the form and belongs in a stable context. Values, errors and derived flags change on every keystroke and must not be in context; fields read them through per-path subscriptions to the store. Category Changes Belongs in actions (setValue, submit) never stable context store reference never stable context values, errors, isDirty every keystroke NOT context: per-path subscriptions

The core pattern: stable context, subscribed slices

import { createContext, useContext, useMemo, useRef, useSyncExternalStore, useCallback, memo } from "react";
import { createFormStore, type FormStore } from "./form-store";   // from the store guide

type Values = Record<string, string>;

interface FormApi {
  store: FormStore<Values>;
  setValue: (name: string, value: string) => void;
  submit: () => void;
}

// ONE context, whose value never changes identity for the life of the form.
const FormApiContext = createContext<FormApi | null>(null);

export function FormProvider({ initial, onSubmit, children }: {
  initial: Values; onSubmit: (v: Values) => void; children: React.ReactNode;
}) {
  const storeRef = useRef<FormStore<Values>>();
  storeRef.current ??= createFormStore(initial);        // created once
  const onSubmitRef = useRef(onSubmit);
  onSubmitRef.current = onSubmit;                        // latest callback without changing identity

  const api = useMemo<FormApi>(() => ({
    store: storeRef.current!,
    setValue: (name, value) => storeRef.current!.set(name, value),
    submit: () => onSubmitRef.current(storeRef.current!.getAll()),
  }), []);                                               // empty deps: stable forever

  return <FormApiContext.Provider value={api}>{children}</FormApiContext.Provider>;
}

function useFormApi() {
  const api = useContext(FormApiContext);
  if (!api) throw new Error("useFormApi must be used inside <FormProvider>");
  return api;
}

// Each field reads ONLY its own value.
export function useField(name: string) {
  const { store, setValue } = useFormApi();
  const subscribe = useCallback((l: () => void) => store.subscribePath(name, l), [store, name]);
  const value = useSyncExternalStore(subscribe, () => store.get(name), () => store.get(name));
  return { value, onChange: (e: React.ChangeEvent<HTMLInputElement>) => setValue(name, e.target.value) };
}

export const Field = memo(function Field({ name, label }: { name: string; label: string }) {
  const { value, onChange } = useField(name);
  return (<><label htmlFor={name}>{label}</label><input id={name} name={name} value={value} onChange={onChange} /></>);
});

The provider re-renders when its parent does, but api keeps the same identity, so context consumers do not re-render because of it. Fields re-render only when their own path changes.


Step-by-step walkthrough

  1. Measure first. Record a keystroke with the React Profiler and count how many field components rendered; the procedure is in profiling form re-renders in DevTools.
  2. Move values into a store. Create it once per form with a ref; the store object’s identity never changes.
  3. Make the context value stable. Build it with useMemo(() => …, []) and read changing callbacks (like onSubmit) through refs so they do not force a new context value.
  4. Subscribe fields to their own path. useSyncExternalStore with a per-path subscribe function; the field renders only when its value changes.
  5. Wrap fields in memo. With a stable context, memo now works: a parent re-render passes the same props and the field bails out.
  6. Re-measure. The same keystroke should now render one field component plus any derived-state subscribers.
Field components rendered per keystroke Counting renders for one keystroke in a 150 field form. With one context containing values, all 150 fields render. With a stable context but fields still reading all values from the store, all 150 still render. With a stable context and per-path subscriptions, one field renders. one context with values 150 fields stable context, read all values 150 fields stable context + path subscriptions 1 field Counts follow from the design, not from timing: only the last design limits notification to the edited path.

Where derived and cross-field state should live

Once values leave context, the question becomes where things like “show the VAT field when the country is in the EU” or “total of all line items” should be computed. The answer is: in the component that renders the result, subscribed to exactly the inputs it depends on. A conditional section subscribes to country and renders its fields only when the rule holds; a totals row subscribes to the line-item paths and returns a number. Each of these is a small consumer with a narrow subscription, so a keystroke in an unrelated field wakes none of them.

Resist the urge to recreate a central “form view model” that computes everything and passes it down — that object changes whenever any input to it changes, and every component reading it is back to rendering on every keystroke. Many small subscriptions are cheaper than one large derived object, both to run and to reason about when profiling shows an unexpected render.


Failure modes and edge cases

1. Recreating the context value inline

<Ctx.Provider value={{ store, setValue }}> creates a new object each render, re-rendering every consumer. Memoise it, and check that its dependencies are themselves stable.

2. Splitting into values and actions contexts only

A common half-fix is two contexts: ValuesContext and ActionsContext. Components that only need actions stop re-rendering, but every field needs its value, so every field still consumes ValuesContext and still re-renders together. The per-path subscription is what isolates fields.

3. Derived state in the provider

Computing isDirty or errorCount in the provider and passing it down re-creates the cascade. Compute derived state in the components that need it, via subscriptions that return primitives.

4. Callbacks captured with stale values

If submit closes over values captured at provider render time, it submits stale data. Read from the store at call time (store.getAll()), as submit does above.

5. Context selectors

Libraries such as use-context-selector let consumers select a slice of a context value. They achieve the same isolation with a different API; the underlying rule — do not let every consumer see every change — is the same.

Before and after, as a component tree Before, the provider holds values in its context value; a keystroke in email creates a new context value, so the provider's consumers — every field, the error summary and the submit button — all re-render. After, the provider holds a stable API object; the keystroke notifies the email field's subscription and any derived subscribers such as the dirty indicator, and nothing else renders. Before Provider value = { values, errors, … }. Keystroke → new context value. Every field, summary and button re-render. After Provider value = stable { store, actions }. Keystroke → email subscription only. Plus the dirty indicator if its answer flips.

Verification checklist


Frequently Asked Questions

Will the React Compiler fix this automatically?

The compiler memoises components and values so that stable inputs produce skipped renders, which removes a lot of manual memo and useMemo. It does not change context semantics: a context value that changes every keystroke still re-renders every consumer. Splitting state out of context is still necessary.

Is one context per field a solution?

It isolates renders but requires a provider per field and does not scale to dynamic fields. A single stable context plus per-path store subscriptions gives the same isolation with one provider.

How does React Hook Form avoid this?

It keeps values in refs and a subscription system outside React state, and useController / useWatch subscribe to specific fields. Its FormProvider context holds the stable control object, not values — the same pattern as this page.


Related

← React Form Hook Architecture