TIL

Radix UI - Composition

文章發表於
...

Introduction

Composition is the foundation for building modern UI components. Rather than cramming all functionality into a single component with numerous props, composition distributes responsibility across multiple collaborating components that work together seamlessly.

This pattern mirrors how we write native HTML. Consider the native select element:

<select>
<option>Apple</option>
<option>Banana</option>
<option>Orange</option>
</select>

The <select> doesn't need to know the exact content of each option upfront. Each <option> self-registers with its parent, and the browser handles the coordination automatically. This elegant separation of concerns is what we want to achieve in React.

Many UI patterns follow this compound structure: Dropdowns, Menus, Accordions, Tabs, and Dialogs. They all share a common challenge in React: how do parent and child components communicate effectively without tight coupling?

<Select.Root>
<Select.Trigger>Choose a fruit</Select.Trigger>
<Select.Content>
<Select.Item value="apple">Apple</Select.Item>
<Select.Item value="banana">Banana</Select.Item>
<Select.Item value="orange">Orange</Select.Item>
</Select.Content>
</Select.Root>

The questions that arise are fundamental:

  1. How does Select.Root know which Select.Item is currently selected?
  2. How does each Select.Item know its position for keyboard navigation? How do we maintain this coordination when items are nested inside wrapper elements?

Let's explore three approaches to solving these challenges, using a Select component as our example.


Approach 1: Configuration Props

The most straightforward approach passes data as configuration props to the parent component, which then renders everything internally.

Implementation

import React from 'react'
import { Select } from './select.js'

const options = [
  { value: 'apple', label: 'Apple' },
  { value: 'banana', label: 'Banana' },
  { value: 'orange', label: 'Orange' },
]

export default () => {
  return (
    <Select
      defaultValue="apple"
      options={options}
      placeholder="Choose a fruit"
    />
  )
}

This approach works for simple cases, but the component quickly becomes a monolith. All rendering logic, state management, and styling live in one place. Customizing individual items, say adding an icon to one option or disabling another, requires adding more configuration options or render props, leading to an ever-growing API surface. The component ends up controlling everything, making it difficult to extend without modifying the source.

For example, the PM asks: "Can one option show a tooltip explaining why it's unavailable?" Now you need renderOption or a tooltip field. Then design says: "We need a divider between the fruit categories, and the seasonal section needs a 'Show more' button." A flat options array can't express this—you need groups, renderGroupHeader, renderGroupFooter. Then the team asks: "When users select 'Custom...', it should open a modal instead of selecting." Now you need onItemClick that can prevent default behavior, or a customAction field.

<Select
options={options}
groups={groups}
renderOption={(opt) => ...}
renderGroupHeader={(group) => ...}
renderGroupFooter={(group) => ...}
onItemClick={(opt, e) => { if (opt.custom) e.preventDefault() }}
// ...and it keeps growing
/>

Each structural or behavioral variation requires new props. Consumers needing slightly different behavior must fork the component or request yet another escape hatch.


Approach 2: Using cloneElement

The cloneElement approach allows for a more declarative API by letting consumers write JSX children directly. The parent iterates through its children and injects the necessary props.

Implementation

import React from 'react'
import { SelectRoot, SelectTrigger, SelectContent, SelectItem } from './select.js'

export default () => {
  return (
    <SelectRoot defaultValue="apple">
      <SelectTrigger>Choose a fruit</SelectTrigger>
      <SelectContent>
        <SelectItem value="apple">Apple</SelectItem>
        <SelectItem value="banana">Banana</SelectItem>
        <SelectItem value="orange">Orange</SelectItem>
        {/* Uncomment to see the limitation */}
        {/* <div className="group">
          <SelectItem value="cherry">Cherry</SelectItem>
        </div> */}
      </SelectContent>
    </SelectRoot>
  )
}

This solves the props explosion problem. Need a tooltip on one item? Just add it. Need a divider? Insert one between items. Need a custom action? Handle it in that item's onClick. The declarative JSX approach gives consumers full control over what they render.

However, cloneElement has a fundamental limitation: it only operates on direct children. The moment requirements demand structural flexibility, it breaks. Design asks: "Can we wrap the seasonal fruits in an animated container?" You try:

<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<AnimatedGroup>
<SelectItem value="mango">Mango</SelectItem>
<SelectItem value="papaya">Papaya</SelectItem>
</AnimatedGroup>
</SelectContent>

The items inside AnimatedGroup no longer receive the injected props—cloneElement can't reach them through the wrapper. Uncomment the wrapped item in the example above to see this break.


Approach 3: Collection API

The Collection API solves the structural rigidity problem by having each item register itself with a shared collection context, regardless of where it appears in the component tree. This is the pattern Radix UI uses internally.

Rather than relying on the parent to iterate children, each item announces its existence through a hook. The collection maintains a map of all registered items, tracks their DOM order, and provides this information back to any component that needs it.

Implementation

import React from 'react'
import { SelectRoot, SelectTrigger, SelectContent, SelectItem } from './select.js'

export default () => {
  return (
    <SelectRoot defaultValue="apple">
      <SelectTrigger>Choose a fruit</SelectTrigger>
      <SelectContent>
        <SelectItem value="apple">Apple</SelectItem>
        <SelectItem value="banana">Banana</SelectItem>
        <SelectItem value="orange">Orange</SelectItem>
        {/* Now this works! Items can be nested anywhere */}
        <div className="group">
          <SelectItem value="cherry">Cherry</SelectItem>
        </div>
      </SelectContent>
    </SelectRoot>
  )
}

The Collection API decouples item registration from DOM structure. Each SelectItem calls useCollectionItem to register itself, receiving back a ref and its index in the collection. The parent doesn't need to know about its children's structure at all.

This enables powerful patterns: items can be wrapped in any number of intermediate elements, conditionally rendered, or dynamically generated. The collection tracks them based on actual DOM order, not JSX structure. Keyboard navigation becomes straightforward because the collection knows every item and their positions. This pattern also fully supports Server Side Rendering by tracking registration counts during the initial render pass.

The Collection API is what powers Radix UI's compound components. It's the foundation that enables their flexible, accessible primitives to work regardless of how consumers structure their JSX.


Naming Conventions

You'll notice Radix UI components follow predictable naming patterns. Understanding these conventions makes the API intuitive once you've learned one component.

Root is the outer boundary. It establishes context and coordinates state between its children, think of it as the component's "brain"

<Accordion.Root>
{/* Everything lives inside Root */}
</Accordion.Root>

Trigger and Content form the interactive pair. The trigger is what users click; the content is what appears in response.

<Collapsible.Trigger>Toggle</Collapsible.Trigger>
<Collapsible.Content>Now you see me</Collapsible.Content>

For components with distinct sections, you'll see Header, Body, and Footer, borrowing directly from HTML's document structure.

<Dialog.Header>...</Dialog.Header>
<Dialog.Body>...</Dialog.Body>
<Dialog.Footer>...</Dialog.Footer>

Title and Description handle text hierarchy. Title is the primary label; Description provides secondary context.

<Card.Title>Monthly Report</Card.Title>
<Card.Description>Updated every 30 days</Card.Description>

References

If you enjoyed this article, please click the buttons below to share it with more people. Your support means a lot to me as a writer.
Share on X
Buy me a coffee