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.
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
- 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.
- Move values into a store. Create it once per form with a ref; the store object’s identity never changes.
- Make the context value stable. Build it with
useMemo(() => …, [])and read changing callbacks (likeonSubmit) through refs so they do not force a new context value. - Subscribe fields to their own path.
useSyncExternalStorewith a per-path subscribe function; the field renders only when its value changes. - Wrap fields in
memo. With a stable context,memonow works: a parent re-render passes the same props and the field bails out. - Re-measure. The same keystroke should now render one field component plus any derived-state subscribers.
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.
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.