Skip to content

Fix Maximum update depth exceeded in TabbedForm under React 19 - #11379

Merged
fzaninotto merged 3 commits into
marmelab:masterfrom
Sophran-fbj:fix/useformgroup-react19-max-update-depth
Sep 22, 2026
Merged

fzaninotto merged 3 commits into
marmelab:masterfrom
Sophran-fbj:fix/useformgroup-react19-max-update-depth

Conversation

@Sophran-fbj

@Sophran-fbj Sophran-fbj commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A <TabbedForm> with several tabs and many fields throws Maximum update depth exceeded under React 19 when submitting, and the form silently fails to save.

useFormGroup computed its state in a useState + useEffect pair. Every useFormState() snapshot change re-rendered the consumer, then the effect called setState, scheduling an additional commit. During a full-form validation pass, react-hook-form flips validatingFields one microtask at a time; with one useFormGroup per tab, those extra commit-phase updates chain and React 19 throws.

Fixes #11368.

Solution

Compute the group state during render with useMemo instead of storing it in state and updating it from an effect. The derived state now lands in the same commit as the useFormState() update that triggered it, so no additional commit is scheduled.

Group field registration is exposed as an external store through the existing subscribe and getGroupFields methods. useSyncExternalStore reads 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.validate in useInput/ArrayInputBase when 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)

  • New regression test: the group state must never lag behind the form state it derives from. It fails on master with a stale observation ({ value: 'test', isDirty: false }) and passes with the fix.
  • New test: the group state is recomputed when a field is added to or removed from the group.
  • Existing tests (group state, dirty/touched, group switch, ArrayInput) still pass.
yarn test-unit packages/ra-core/src/form/groups/useFormGroup.spec.tsx
yarn test-unit packages/ra-core/src/form packages/ra-ui-materialui/src/form

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:

master with this fix
Maximum update depth exceeded 2 (page error + unhandled rejection) 0
Save succeeds ("updated" notification) no yes

Reproduction: examples/simple, react/react-dom forced to 19.2.8 via resolutions, fill one field to enable the Save button, submit.

Checklist

  • Unit tests added
  • Typecheck / lint / prettier pass
  • No breaking change (the hook return value and semantics are unchanged)

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
Comment on lines +128 to +129
formGroups,
groupFieldsVersion,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@fzaninotto fzaninotto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

almost perfect!

const updateGroupState = useEvent(() => {
if (!formGroups) return;
const fields = formGroups.getGroupFields(name);
const { subscribe, getSnapshot } = useMemo(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this double function memoization using useMemo feels weird, I'd prefer two useCallback calls.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 18245c4: subscribe and getSnapshot now use separate useCallback calls.

@fzaninotto
fzaninotto merged commit 0b56c4f into marmelab:master Sep 22, 2026
14 checks passed
@fzaninotto

Copy link
Copy Markdown
Member

Awesome, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Maximum update depth exceeded in TabbedForm under React 19 (useFormGroup schedules a commit-phase update per validation tick)

2 participants