Repository navigation
Fix Maximum update depth exceeded in TabbedForm under React 19 - #11379
fzaninotto merged 3 commits into
Conversation
useFormGroup computed its state in an effect, so every form state update scheduled an additional commit. During a full-form validation pass, react-hook-form flips validatingFields one microtask at a time; with several TabbedForm tabs and many fields, these chained commit-phase updates make React 19 throw "Maximum update depth exceeded", and the form silently fails to save. Computing the state with useMemo folds it into the commit of the useFormState update it derives from. Group content changes still trigger a recompute through the existing subscription. Fixes marmelab#11368
| formGroups, | ||
| groupFieldsVersion, |
There was a problem hiding this comment.
I don't understand why you need both groupFieldVersion and the pair name/formGroups, as the former will change when the latter changes.
I believe name & formGroup are enough. Can you explain me why you need the version?
There was a problem hiding this comment.
name and formGroups do cause the subscription effect to run again when either reference changes, but the reverse case is the reason for groupFieldsVersion: the fields inside a group can change while both name and formGroups remain stable.
FormGroupsProvider creates the formGroups context value with useMemo(..., []). registerField and unregisterField update its internal ref and notify subscribers without changing the context value. For example, when a conditional input is mounted or unmounted, neither name nor formGroups changes, so a memo depending only on those two values would keep the previous field list.
The subscription increments groupFieldsVersion to invalidate the memo for that case. The added “field is added to or removed from the group” test covers this behavior.
There was a problem hiding this comment.
Sorry, it's still not clear for me:
- groupFieldsVersion is incremented in an effect when either formGroups or name change
- your useMemo has a dependency array including groupFieldsVersion, formGroups, and name.
So something is wrong:
- either we don't need the groupFieldsVersion in the useMemo dependencies (formGroups and name are enough)
- or we don't need formGroups and name in the useMemo dependencies (if the returned value should just return after a tick)
There was a problem hiding this comment.
Thanks, I see why the dependency relationship was unclear. I refactored this in e51d4c7 to use useSyncExternalStore over the existing subscribe/getGroupFields API. The memo now depends on the actual fields snapshot, so the version counter and the indirect name/formGroups dependencies are gone.
| const updateGroupState = useEvent(() => { | ||
| if (!formGroups) return; | ||
| const fields = formGroups.getGroupFields(name); | ||
| const { subscribe, getSnapshot } = useMemo(() => { |
There was a problem hiding this comment.
this double function memoization using useMemo feels weird, I'd prefer two useCallback calls.
There was a problem hiding this comment.
Updated in 18245c4: subscribe and getSnapshot now use separate useCallback calls.
|
Awesome, thanks! |
Problem
A
<TabbedForm>with several tabs and many fields throwsMaximum update depth exceededunder React 19 when submitting, and the form silently fails to save.useFormGroupcomputed its state in auseState+useEffectpair. EveryuseFormState()snapshot change re-rendered the consumer, then the effect calledsetState, scheduling an additional commit. During a full-form validation pass, react-hook-form flipsvalidatingFieldsone microtask at a time; with oneuseFormGroupper tab, those extra commit-phase updates chain and React 19 throws.Fixes #11368.
Solution
Compute the group state during render with
useMemoinstead of storing it in state and updating it from an effect. The derived state now lands in the same commit as theuseFormState()update that triggered it, so no additional commit is scheduled.Group field registration is exposed as an external store through the existing
subscribeandgetGroupFieldsmethods.useSyncExternalStorereads the actual field list snapshot, so registering or unregistering an input invalidates the derived state directly without an intermediate version counter. It also handles a field registration that occurs between render and subscription.This is the same class of bug as #11329 (
<ReferenceField>), and follows the same strategy: remove the extra commit rather than mitigate the trigger.Out of scope: the issue also suggests only registering
rules.validateinuseInput/ArrayInputBasewhen a validator exists. That is a separate, behavior-changing optimization and is not included here.How to test
Unit tests (React 18, the repo default)
masterwith a stale observation ({ value: 'test', isDirty: false }) and passes with the fix.Manual verification in a real browser (React 19)
The unit tests run with React 18, where the crash does not occur, so the regression test asserts the underlying invariant (no extra commit, no stale state) rather than the exception itself.
To validate the reported scenario, the simple example was run with React 19.2.8 and a temporary 7-tab x 20-field
TabbedForm(140 fields), submitted in headless Chrome:masterMaximum update depth exceededReproduction:
examples/simple,react/react-domforced to 19.2.8 viaresolutions, fill one field to enable the Save button, submit.Checklist