Dropping Radix UI: Going Fully Custom for React 19

How a React 19 infinite re-render loop led us to replace all third-party UI primitives with 660 lines of custom components.

A React 19 infinite re-render loop in Radix UI led us to remove all third-party UI primitives. The result is better than what we started with.


The bug that started it all

We upgraded to React 19 and the Query Editor page crashed:

Maximum update depth exceeded. This can happen when a component
repeatedly calls setState inside componentWillUpdate or componentDidUpdate.

The stack trace pointed to setRef in @radix-ui/react-compose-refs. Every render, Radix’s SlotClone component created a new composed ref function. React 19’s ref cleanup semantics detected the changing identity, detached the old ref, attached the new one, which triggered setState, which re-rendered, which created a new ref. Infinite loop.

The attempted fixes

Fix 1: Patch compose-refs (didn’t work)

We patched @radix-ui/react-compose-refs via pnpm to stabilize the ref identity using useRef. The patch fixed useComposedRefs but the loop was in composeRefs called directly from SlotClone. A different code path.

Fix 2: Remove forwardRef from our wrapper (didn’t work)

We removed React.forwardRef from our SelectTrigger wrapper, hoping to eliminate the extra ref composition layer. The loop was inside Radix’s own SelectTrigger, not our wrapper.

Fix 3: Migrate to React Aria (partially worked)

We replaced all Radix packages with react-aria-components. Dialog, Tabs, Switch, Separator worked. But Select (using ListBox + ListBoxItem) had the exact same class of bug. React Aria’s NodeValue.setProps triggered forceStoreRerender on ref attachment.

Then React Aria’s Modal focus trap blocked clicks on our Select portal (which renders at document.body, outside the Modal). Selects inside Dialogs were unclickable.

Fix 4: Go fully custom (worked)

We wrote our own implementations for all 8 UI primitives:

ComponentLinesKey features
Dialog90Portal, backdrop, Escape, focus save/restore, body scroll lock
Select240Portal, keyboard nav, type-to-search, ARIA combobox/listbox
DropdownMenu150Keyboard nav, ARIA menu/menuitem roles, focus management
Tabs100Context-based, ARIA tablist/tab/tabpanel
Switch45role=switch, aria-checked, keyboard toggle
Separator15role=separator
Label10Plain HTML label + CVA
Button10Plain HTML button + CVA variants

Total: about 660 lines replacing 8 Radix packages + react-aria-components (170+ transitive dependencies).

The bugs we had to fix ourselves

Going custom means owning every edge case.

Click-outside timing

When the Select portal opens, the mousedown listener fires before the portal is in the DOM. The click-outside handler detects the click is “outside” and immediately closes it. Fix: defer the listener registration by one requestAnimationFrame.

Portal clicks inside Dialog

Our Dialog uses createPortal to document.body. The Select also portals to document.body. Since both are at the same DOM level, neither “contains” the other. We added onMouseDown={e => e.stopPropagation()} on the Select portal to prevent the Dialog’s click-outside from catching it.

Select Escape vs Dialog Escape

Both Select and Dialog listen for Escape on document. Without coordination, pressing Escape on an open Select also closes the Dialog. Fix: Select uses capture phase + stopImmediatePropagation(), so it intercepts before the Dialog’s handler.

Nested dialogs

Two Dialogs both listen for Escape in bubble phase. The inner dialog calls preventDefault(), and the outer checks defaultPrevented before closing. Only the innermost dialog responds.

What we gained

Zero infinite loops. No library ref composition, no NodeValue, no forceStoreRerender. Plain React state + createPortal.

Zero dependencies. Removed 8 @radix-ui/* packages and react-aria-components. About 170 transitive dependencies gone. Smaller node_modules, faster installs, smaller bundle.

Full control. When the Select needs scroll/resize repositioning, we add event listeners. When the DropdownMenu needs keyboard navigation, we implement it. No fighting library abstractions.

Better accessibility. Our Select has aria-controls, aria-activedescendant, role=combobox, role=listbox, role=option with proper focus tracking. Our DropdownMenu has role=menu, role=menuitem, role=menuitemcheckbox. We built what we needed, not what a generic library provides.

We would do it again

The 660 lines of custom components are:

  • Easier to debug than library internals
  • Easier to modify than patching node_modules
  • Smaller than any headless UI library
  • Tested with our own test suite (select.test.tsx, dialog.test.tsx, dropdown-menu.test.tsx)

The React 19 ref-callback change exposed a fundamental architectural issue in both Radix and React Aria’s collection-based components. Until these libraries ship stable React 19 support, custom implementations are the pragmatic choice.


The Radix to custom migration was performed in April 2026. The full audit and fix history is tracked in docs/ISSUES.md.

All posts