-
-
Notifications
You must be signed in to change notification settings - Fork 586
Description
Bug description
useField runs fieldApi.update(opts) inside a useIsomorphicLayoutEffect with no dependency array, causing it to fire during every commit phase. Despite the comment saying "should not have any side effects", fieldApi.update() conditionally calls setFieldValue and setFieldMeta, which trigger store notifications.
This creates infinite re-render loops when combined with any component that triggers a state update or ref change during React's commit phase.
Affected components
Confirmed to cause infinite loops with:
@radix-ui/react-select—SelectTriggerpasses a state setter as a ref viacomposeRefs@radix-ui/react-checkbox— UsesuseSize(element)which callssetSize()inuseLayoutEffect@dnd-kit/core—useDroppabledispatches actions inuseEffect
Reproduction
Minimal repro repo: https://github.com/yannisgu/radix-tanstack-form-react19-repro
git clone https://github.com/yannisgu/radix-tanstack-form-react19-repro
cd radix-tanstack-form-react19-repro/radix-select-tanstack-form
pnpm install && pnpm dev
# Open http://localhost:5173 — tab freezesThe repro uses composeRefs from @radix-ui/react-compose-refs (same pattern used internally by Radix Select, Checkbox, etc.) with React 19 and @tanstack/react-form.
function BuggyInput({ value, onChange }) {
const [, setNode] = useState(null); // state setter as ref callback
const internalRef = useRef(null);
const ref = composeRefs(internalRef, setNode); // new function every render
return <input ref={ref} value={value} onChange={onChange} />;
}
<form.Field name="name">
{(field) => (
<BuggyInput value={field.state.value} onChange={field.handleChange} />
)}
</form.Field>Root cause
The loop is caused by the interaction of two issues:
1. useField — layout effect with no deps (useField.tsx:345-347)
useIsomorphicLayoutEffect(() => {
fieldApi.update(opts)
})This runs during every commit phase. fieldApi.update() calls setFieldMeta and setFieldValue conditionally, but any store notification triggers a re-render.
2. React 19 ref cleanup + unstable ref identity
In React 19, callback refs can return cleanup functions. When composeRefs() (used by @radix-ui/react-slot's SlotClone) creates a new function every render, React unmounts the old ref and mounts the new one. If a state setter is one of the composed refs, this triggers a state update during commit.
The cycle
useFieldeffect fires (no deps) →fieldApi.update(opts)→ potentialsetFieldMeta→ store notification → re-rendercomposeRefs()creates new ref function → React unmount/remount → state setter fires during commit → re-render- Go to step 1
The loop is synchronous (during React's commit phase), so the tab freezes rather than throwing "Maximum update depth exceeded".
Suggested fix
The comment says fieldApi.update should be "like a useRef". Make it literally so:
// Instead of:
useIsomorphicLayoutEffect(() => {
fieldApi.update(opts)
})
// Just assign during render:
fieldApi.options = opts
fieldApi.name = opts.nameThe initialization side effects in update() (default value + meta registration) are already handled by fieldApi.mount() which calls this.update() during its own layout effect.
See PR #2019 for a proposed implementation.
Environment
- React 19.x
- @tanstack/react-form 1.28.x
- @radix-ui/react-compose-refs 1.1.2
- Any browser (tab freezes on all)