Skip to content

useField's no-deps layout effect causes infinite loops with React 19 + Radix UI components #2020

@yannisgu

Description

@yannisgu

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-selectSelectTrigger passes a state setter as a ref via composeRefs
  • @radix-ui/react-checkbox — Uses useSize(element) which calls setSize() in useLayoutEffect
  • @dnd-kit/coreuseDroppable dispatches actions in useEffect

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 freezes

The 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

  1. useField effect fires (no deps) → fieldApi.update(opts) → potential setFieldMeta → store notification → re-render
  2. composeRefs() creates new ref function → React unmount/remount → state setter fires during commit → re-render
  3. 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.name

The 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions