Composable component APIs

4 min read
Table of Contents

For those of us who have followed the evolution of UI component libraries in the React ecosystem, it’s hard not be inspired by the elegant composable APIs exemplified by react-aria and radix-ui. Is this also the approach we should follow when authoring internal component libraries, for example internal design-system implementations?

import React from 'react';
import * as Select from '@radix-ui/react-select';
import classnames from 'classnames';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
import './styles.css';
const SelectDemo = () => (
<Select.Root>
<Select.Trigger className="SelectTrigger" aria-label="Food">
<Select.Value placeholder="Select a fruit…" />
<Select.Icon className="SelectIcon">
<ChevronDownIcon />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content className="SelectContent">
<Select.ScrollUpButton className="SelectScrollButton">
<ChevronUpIcon />
</Select.ScrollUpButton>
<Select.Viewport className="SelectViewport">
<Select.Group>
<Select.Label className="SelectLabel">Fruits</Select.Label>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="blueberry">Blueberry</SelectItem>
<SelectItem value="grapes">Grapes</SelectItem>
<SelectItem value="pineapple">Pineapple</SelectItem>
</Select.Group>
</Select.Content>
</Select.Portal>
</Select.Root>
);

Isn’t that lovely? That’s example is copied and condensed from the radix-ui examples. Clear hierarchy, flexibility via composition, looks almost like HTML. Right? RIGHT?

Well…

Much as I like it, I am not convinced it is the style I would recommend exposing to teams, particularly if you have to support developers with limited frontend experience.

Let me outline some of the reasons why:

Rules of composition are opaqueLink to heading

When you look at the Radix Select example above, it’s not immediately obvious which components are required, which are optional, or what the valid nesting relationships are. Can Select.Icon exist outside of Select.Trigger? What happens if you forget Select.Portal?

These rules exist in the documentation and TypeScript definitions, but they’re not self-evident from the API surface. A developer needs to understand the mental model of how these pieces fit together, which adds cognitive overhead.

Compare this to a more traditional API:

<Select
options={fruits}
placeholder="Select a fruit…"
icon={<ChevronDownIcon />}
/>

The constraints are clear: you pass props, you get a select. No composition rules to remember.

Behaviour of composed children is opaqueLink to heading

In composable APIs, child components often receive props or context invisibly from their parents. In our Radix example, Select.Value somehow knows what the current selection is, and Select.Item components can update that selection - but this magic happens behind the scenes.

This can lead to confusing debugging sessions. When something doesn’t work as expected, developers need to understand not just their own code, but the implicit data flow between composed components.

// Why doesn't this work? What's missing?
<Select.Root>
<Select.Trigger>
<Select.Value /> {/* No placeholder appears */}
</Select.Trigger>
</Select.Root>

The answer might be that you need defaultValue on Select.Root, or that Select.Value needs a placeholder prop, but this isn’t obvious from the component structure alone.

Internal APIs sometimes benefit from opinions over flexibilityLink to heading

Public libraries like Radix need to serve diverse use cases across thousands of applications. They optimize for flexibility because they can’t predict every scenario their users will encounter.

Internal component libraries have different constraints. You typically know:

  • Your design system’s specific patterns
  • Your team’s skill levels and preferences
  • The specific use cases you need to support
  • Your organization’s accessibility and testing requirements

This knowledge lets you make opinionated choices that reduce complexity:

// Opinionated: always includes error state, loading state, proper labeling
<FormSelect
label="Favorite fruit"
options={fruits}
value={selectedFruit}
onChange={setSelectedFruit}
error={validationError}
loading={isLoading}
required
/>

Rather than requiring developers to compose these concerns themselves:

<Select.Root value={selectedFruit} onValueChange={setSelectedFruit}>
<Label htmlFor="fruit-select" required>Favorite fruit</Label>
<Select.Trigger id="fruit-select" disabled={isLoading}>
{isLoading ? <Spinner /> : <Select.Value />}
<Select.Icon><ChevronDownIcon /></Select.Icon>
</Select.Trigger>
{/* ... rest of composition */}
{validationError && <ErrorMessage>{validationError}</ErrorMessage>}
</Select.Root>

Finding the right balanceLink to heading

This doesn’t mean composable APIs are always wrong for internal libraries. They excel when you need:

  • Complex, varied layouts that can’t be anticipated
  • Components that need to integrate with diverse parent contexts
  • Maximum flexibility for power users

But for most internal design systems, I’d recommend starting with simpler, more opinionated APIs and only introducing composition where the flexibility is genuinely needed. Your future self (and your teammates) will thank you for the reduced cognitive load.

The goal isn’t to build the most elegant API - it’s to build the most effective one for your team and context.

My avatar

Thanks for reading my blog post! Feel free to check out my other posts or contact me via the social links in the footer.


More Posts

Comments