Component Composition
How much control should a component give to its consumers?
The Problem
My team built a Tabs component. It accepted a tabs prop—an array of objects with id, label, and content. It worked great for two months.
Then product asked for a notification badge on the Messages tab. Easy fix: add a badge prop. The next week: icons on some tabs. Another prop. Then: a tooltip on the disabled Settings tab. Another prop. Then: a different tab label style for mobile. The component grew from 50 to 300 lines, each feature a new if and a new prop. Every new requirement meant touching the component itself.
This is the composition problem. When a component owns all of its own rendering, consumers can only customize through props—and every possible variation must be anticipated in advance.
badge prop on the component — new features need library changes.Manage your profile, email, and password.
The Solution
Invert the control. Instead of the component owning rendering, give rendering back to the consumer. The component keeps what it's good at—managing active state, keyboard navigation, ARIA attributes—and exposes composable pieces the consumer assembles.
With compound components, the badge moves from a prop to plain JSX inside a Tab. The Tabs component never needs to know badges exist. Next week's icon request? Consumer adds an icon element. Tooltip? Consumer wraps the Tab in a Tooltip. The component's API is stable forever.
Manage your profile, email, and password.
The Framework
There are four distinct levels of composition, each transferring more rendering control to the consumer. The question isn't which is best—it's which is appropriate for the component's use case.
Props
Component owns all rendering. Consumer passes data and configuration.
Reach for this when: The component has a fixed layout, a small number of variants, or is used internally where all variations are known upfront. Simple, self-documenting API.
Render Props
Component provides state; consumer provides a function that renders one specific slot.
Reach for this when: You need consumer control over one specific rendering slice, not the whole structure. Common in virtualized lists, tooltip triggers, or table cells where performance matters and you can't use children.
Compound Components
Related components share state via Context. Consumer assembles the structure using idiomatic JSX.
Reach for this when: Building a UI library component where consumers need structural control—tabs, accordions, menus, selects, dialogs. This is how Radix UI, Headless UI, and Ark UI are designed. The API is discoverable and the JSX reads naturally.
Headless / Hook
A hook returns only state and behavior. No rendering whatsoever—the consumer builds the entire UI.
Reach for this when: Building a design system where each consumer has a completely different visual design, or when you need to reuse behavior (keyboard nav, ARIA, focus management) across totally different UI shapes. The foundation for accessibility-focused libraries like React Aria and Radix Primitives.
The key question: who knows what?
The right composition level depends on the information asymmetry between component and consumer. If the component knows everything it needs to render, use props. If the consumer knows better what should be rendered, give them control via compound components or a headless hook. The library author doesn't know every badge, icon, or tooltip the product team will ask for—so don't make them decide.
Decision Matrix
A quick reference for choosing the right level. These aren't mutually exclusive—a complex component often uses render props for one slot and compound components for structure.
| Pattern | Who Renders | Flexibility | Discoverability | Use When |
|---|---|---|---|---|
| Props | Component | Low | High | Fixed layout, few variants, internal use Ex: Simple card, avatar, badge |
| Render Props | Consumer (one slot) | Medium | Medium | Need control over one specific rendering slice Ex: Virtual list row, tooltip trigger, table cell |
| Children / Slots | Consumer (content) | Medium | High | Content varies but structure is fixed Ex: Card with header/body/footer slots, dialog |
| Compound Components | Consumer (structure) | High | High | Related pieces that share state; UI library component Ex: Tabs, accordion, menu, select |
| Headless / Hook | Consumer (everything) | Maximum | Low | Design system; consumer needs full rendering control Ex: useTabs(), useCombobox(), useVirtualizer() |
Progressive Complexity
The same feature—a Tabs component—built five ways. Each step adds more consumer control and shows exactly when to reach for the next level.
Example 1: Props
SimpleComponent owns all rendering
The simplest case: pass all data as props and let the component own all rendering.
Why this works
When this breaks
Production Patterns
The form that taught me compound components
A FormField component with label, error, required, helpText props. Worked for basic cases. Then designers wanted the label on the right in one context, inline error below the field in another, floating label in a third. Each layout was a new prop combination the component hadn't anticipated.
FormField.Label, FormField.Input, FormField.Error. The compound API shares the id and aria-* wiring via context — consumers get horizontal label, inline error, or floating label by assembling the pieces differently.labelPosition or errorPlacement, the layout is already escaping what a prop API can express. That's when to reach for compound components.The dropdown that needed to be a combobox
A custom Dropdown component with onSelect, onOpen, onClose, renderItem props. It worked well — until one product area needed keyboard navigation with async search, and another needed grouped options with a create-new option at the bottom.
searchable, async, groupBy, renderFooter. The prop count was a symptom — the abstraction was fighting the use cases.Select as the headless base and kept the existing token styles on top. Keyboard navigation, screen reader support, and grouped options came free. The prop surface went from 12 down to 2: the data and a render function for custom items.Common Mistakes & Hot Takes
You'll know when you need them because you'll have a prop called renderHeaderLeft. Compound components have real overhead: they're more complex to build, harder to document, and less obvious for new consumers. But when a component starts accumulating layout-related props—things that control where and how subpieces render—composition is almost always the right answer. The signal isn't the number of props. It's whether the props describe data (fine) or layout (reach for compounds).
The children prop is the most underused API in React. I've seen components with contentLeft, contentRight, topSection, and bottomSection props that should just accept children and let the consumer place whatever they want inside a slot. Most 'config' prop patterns should just be composition. If a consumer is passing JSX to a prop, it should probably be children.
A button that needs to sometimes be a link, sometimes a router link, sometimes a plain div — the right API is asChild (Radix pattern) or the as prop. Not three separate components, not a wrapper div that breaks layout. The asChild pattern merges your component's behavior (click handlers, ARIA) with whatever element the consumer renders. It's cleaner than wrapping and keeps the DOM shallow.
Headless libraries are valuable when you control the design system and need to apply token-based styles to behavior-heavy components. They're overkill for a two-component demo, a prototype, or an app where the design is stable and variations are minimal. The complexity of a headless library — the mental model, the prop-gets pattern, the ARIA wiring — only pays off when you're building for multiple design contexts. Know the tradeoff before adding the dependency.
A Real Rollout
What it actually looks like to refactor a prop-heavy component library — with multiple teams using it, a product that can't stop shipping, and an API that can't break.
Context
Design system shared across three product areas in a mid-size B2B company. Five engineers consuming it, two designers maintaining Figma. The Card component had accumulated 14 props over 18 months — one new prop per product request, each request reasonable on its own.
The problem
Every new product area needed a slightly different card layout — header on the right, actions in the footer, collapsible body. CardProps had hasHeader, headerAction, footerContent, isCollapsible, defaultCollapsed, onCollapse. The component was testing the limits of what a prop-based API can express — and every new request required a design system PR that touched the component, the types, the Storybook story, and the documentation.
The call
Rewrote Card as a compound component: Card.Header, Card.Body, Card.Footer, Card.Actions. Old usage of <Card title="..." /> still worked via a backwards-compatibility shim for two sprints, then the shim was removed. Critically, I did not do this for Button — Button has one layout and one job; compound components there would be complexity for no benefit.
How I ran it
Wrote the migration guide before writing the component — documented the old-prop to subcomponent mapping so consuming teams knew exactly what to change. Ran a codemod for the mechanical conversions (straightforward prop → wrapper substitutions), left complex cases for manual review. Got the two product teams to migrate their most complex card usage first — if the API felt wrong for the hard cases, we needed to know before the simple cases were done. It felt right. The backwards-compat shim bought the necessary migration window without blocking the product teams from shipping features in parallel.
The outcome
The 18-month prop accumulation pattern stopped. When a new product area needed a card with a sticky header and a secondary action button in the footer, they composed it from the primitives without filing a design system request. CardProps went from 14 props to 2 (className, as). The codemod handled 80% of existing usage in one PR. The design system team stopped being a bottleneck for layout variations.