A Keystroke Shouldn't Wake the Whole Form
In a configuration-driven form with hundreds of inputs, typing one character re-rendered nearly every field. The fix had little to do with memoization and a lot to do with deciding who is allowed to listen.
I work on a form engine that builds React Hook Form forms from configuration: a JSON description of fields, containers, repeatable rows, visibility rules and validation conditions. Small forms were fine. On the large ones, with several hundred inputs and nested arrays, typing started to lag. A character would show up a beat after the key press. Users describe that as the form feeling heavy, and it rarely makes it into a bug report.
The profiler showed one keystroke re-rendering nearly every field in the tree. I tried React.memo first and it changed almost nothing, which was worth knowing: if memo doesn't help, the props aren't what's changing. The fields were re-rendering because of what they were subscribed to, and they were subscribed to much more than they needed.
Below is how that got untangled, and the bugs the fix brought with it.
Who is listening
React Hook Form is designed to avoid exactly this. Each field registered through useController receives its own value and its own error, and the library notifies subscribers selectively. The engine undid that at the root. The top-level component called useFormState to know whether the form was valid and which errors existed, then put errors and isValid into a context that every field read.
The chain went like this. A keystroke runs validation. Validation produces a new errors object. The root re-renders, the context value gets a new identity, and every context consumer re-renders with it. Memoizing the fields can't stop that, because a context update goes straight past memo.
The usual workaround is to switch off validation on mount, or to validate less often. That works when validation is a separate step you can postpone. In a configuration-driven form, validation isn't a separate step. The rules live in the configuration, many of them read other fields, and they have to run again whenever one of those fields changes. A form that opens on existing data also needs to show its errors straight away. Turning off the first pass would have removed one slow moment on load and done nothing for the keystrokes, which were slow because of who was listening, not because of when validation ran.
The fix is to stop treating form-wide state as something to broadcast. The engine now listens through form.subscribe, which React Hook Form documents as subscribing to form state without rendering, keeps the latest snapshot in a ref, and exposes a stable object of accessors: getErrors(), getFieldError(name), isFieldValid(name), getIsValid() and subscribe(listener). The context keeps one identity for the whole life of the form. A component that genuinely has to re-render when validity changes, like a submit button, opts in through useSyncExternalStore. Everything else reads the value when it needs it.
On the largest test configuration, with validation still running on every change, that single change took typing from visibly lagging to keeping up with the keyboard.
Form-wide state is cheap to read and expensive to broadcast. Make reading the default and listening the exception.
Broadcasts you don't see
The root was the obvious listener. The harder ones to find were inside the library's own notifications.
Some React Hook Form events carry no field name. A change to form-wide isValid is one. Triggering validation for a list of fields in a repeatable row is another. A notification without a name can't be routed, so it wakes every useController. In a repeatable section with a few hundred cells, typing in one cell re-rendered almost all of the others.
The second hidden listener was a Proxy. fieldState tracks which properties you read and subscribes you to them. That is a good design until something reads properties you didn't intend to. A shared field wrapper that read error and isDirty subscribed every field to form-wide changes. In development it got stranger: React 19's instrumentation enumerates props so it can log them, and in doing so it read isValidating on every field and subscribed all of them. Nothing in the engine's code asked for that subscription; it came from a dev tool reading props.
Both fixes are about routing instead of broadcasting. Validation runs once, and its results are delivered to each affected field by name through setError and clearErrors. A small child component that renders nothing holds the only form-wide useFormState, so nothing else in the tree subscribes to it. And isValidating is made non-enumerable on the wrapped field state, so a tool looking at props can't subscribe a field by accident.
There was one surprise in the other direction. React Hook Form only computes form-wide validity while something subscribes to it. Remove the last listener and the value quietly stops updating. So the engine keeps one subscription whose callback does nothing; its only job is to keep the value computed.
Dependencies point the other way
Configuration-driven forms are full of conditions: this field is required when that one is checked, this date must fall after that one. The engine declared those relationships through React Hook Form's deps option, so a field whose rule read fields A and B declared deps: [A, B].
That looks right, but React Hook Form uses deps the other way round: it means "when I change, revalidate these." So when A changed, nothing revalidated the field that depended on it. This was a correctness bug as much as a performance one, and the broad subscriptions had been hiding it. So much re-rendered and revalidated on every keystroke that the dependent field usually caught up by accident.
The fix was to build the relationship in the right direction. When the configuration loads, the engine walks every condition and builds a reverse index: for each source field, the fields whose rules read it. Inside repeatable rows the index is scoped to the row, so a change in one row doesn't revalidate its siblings. When a value changes, only its dependents are revalidated. Because results can now arrive out of order, each validation pass carries a generation number, and a result from a pass that has already been superseded is dropped instead of written.
Visibility rules need the same idea. Re-evaluating every rule on every change is the visibility version of broadcasting. Knowing which paths each rule reads lets the engine skip the work when a change can't affect it. The index has to understand the rule language completely, though. An early version missed the operators that check whether a value is missing, and fields stayed hidden after they should have appeared.
The bugs the fix introduced
Moving from reactive state to subscriptions changed timing, and timing is where the next bugs came from.
Updates lost before anyone was listening. The form subscribed in a passive effect. React runs a child's passive effects before its parent's, so a field that called setError on mount did it before the form was subscribed. The field showed its error through its own controller, while getErrors() insisted there were none. The subscription now registers in a layout effect, which runs before any passive effect, and syncs the snapshot once right after subscribing.
Partial payloads. A notification's errors could contain only the field that had just been validated, not the whole tree. Deriving validity from it made the form look valid while other fields still had errors. Validity is now read from the complete error state.
No errors is not the same as valid. Before the first validation pass there are no errors, which is different from having passed validation. Treating the two as equal reported a form as valid before anything had checked it.
A callback that kept re-subscribing. A consumer passed onValidationChange inline. It got a new identity on every render, the effect re-subscribed, the new subscription notified immediately, the parent re-rendered, and the cycle repeated. The latest callback now lives in a ref, and the effect depends only on whether a callback exists.
All four produced wrong answers rather than slow ones, which is why this kind of change needs correctness tests at least as much as speed tests.
Measuring what the user feels
Two kinds of tests keep this from sliding back.
The first counts renders. A test renders a large configuration, types into one field, and asserts that an unrelated field rendered zero additional times. It runs in CI and fails as soon as someone reintroduces a broad subscription. It doesn't measure time; it catches the mechanism coming back.
The second runs in a real browser against a production build, because the development build and jsdom both give misleading timings. Each operation is timed from the input event to the second animation frame after it, which is about as close as a script gets to "the change is on screen." Every operation gets warm-up runs, repeated samples and several fresh mounts, and the gate is set on the 95th percentile rather than the average, since the occasional slow keystroke is what people notice.
The test I'd now add to any performance suite is a control: one that deliberately injects slow synchronous work and checks that the gate fails. If a gate has never failed, you don't know that it can. When the gate did fail for real in CI, on the path that acknowledges a save, the fix was to remove render work, not to raise the budget. I've written more about keeping measurements honest in When the Measurement Is the Bug and Measuring Latency Honestly.
What I learned
In a form like this, performance is mostly decided by the subscription graph: which component listens to which piece of state. Memoization only pays off once that graph is right, and most of the work was making it explicit. That meant naming what each field reads, pointing dependencies in the direction the library actually uses, and routing results to specific fields instead of broadcasting them to all of them.
Before: a slow form needs more memoization.
After: a slow form usually has too many listeners. Decide who is allowed to listen, and memoization becomes a detail.
The other lesson is about trust. Almost every step that made the form faster also gave it a new way to be quietly wrong. The render counts and browser gates matter, but if I could keep only a few tests, I'd keep the ones about timing: an update on mount, a partial payload, a stale result arriving late.