renderpx

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.

Live — a working Props-based Tabs with a badge prop
Badge requires a badge prop on the component — new features need library changes.
Account Settings

Manage your profile, email, and password.

Props-based Tabs — every feature is a new proptsx
Loading...

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.

Live — same badge, but consumer JSX. Tabs never changes.
Badge lives in consumer JSX — the Tabs component never needs to change.
Account Settings

Manage your profile, email, and password.

Compound Tabs — consumer owns the renderingtsx
Loading...

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.

1

Props

Component owns all rendering. Consumer passes data and configuration.

tsx
Loading...

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.

2

Render Props

Component provides state; consumer provides a function that renders one specific slot.

tsx
Loading...

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.

3

Compound Components

Related components share state via Context. Consumer assembles the structure using idiomatic JSX.

tsx
Loading...

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.

4

Headless / Hook

A hook returns only state and behavior. No rendering whatsoever—the consumer builds the entire UI.

tsx
Loading...

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.

PatternWho RendersFlexibilityDiscoverabilityUse When
PropsComponentLowHigh
Fixed layout, few variants, internal use
Ex: Simple card, avatar, badge
Render PropsConsumer (one slot)MediumMedium
Need control over one specific rendering slice
Ex: Virtual list row, tooltip trigger, table cell
Children / SlotsConsumer (content)MediumHigh
Content varies but structure is fixed
Ex: Card with header/body/footer slots, dialog
Compound ComponentsConsumer (structure)HighHigh
Related pieces that share state; UI library component
Ex: Tabs, accordion, menu, select
Headless / HookConsumer (everything)MaximumLow
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

Simple

Component owns all rendering

The simplest case: pass all data as props and let the component own all rendering.

tsx
Loading...

Why this works

Works well when: • You control the tab API end-to-end • Tab labels are always simple strings • The layout never needs to change • You're building a one-off, not a library component

When this breaks

Product asks for a notification badge on the Messages tab. You add a "badge" prop. Then they want icons. Another prop. Then disabled tabs. Then a tooltip on hover. Each feature is a prop bolted onto the component — and the component grows to handle every possible rendering variation.

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.

The fix: Rewrote as 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.
The signal: When you find yourself adding a prop like 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.

What happened: Each new requirement meant adding another prop: searchable, async, groupBy, renderFooter. The prop count was a symptom — the abstraction was fighting the use cases.
The fix: Replaced with Radix 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.
The lesson: A growing prop count on a UI component is often a signal that the abstraction is at the wrong level — you're fighting layout variations that composition handles naturally.

Common Mistakes & Hot Takes

Compound components are overkill — until they're not

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

Not using children for content that varies

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.

Wrapping components in divs you don't control

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.

Reaching for Radix (or Headless UI) for everything

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.