Section 3 · Components
Composition Over Configuration
20 min3 min readChoose wisely
Lesson at a glance
Recognize prop explosion and replace it with compound components that share state through context, while knowing when a small configured component is still the better choice.
- 1Configuration versus composition
- 2Sharing state between the pieces
- 3Composition has costs too
- 4Migrating away from a configured component
- 5When configuration is right
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
The first version of Northwind’s ShipmentCard had six props. A year later it had twenty-three: title, subtitle, status, statusTone, showMenu, menuItems, footerText, footerAction, footerActionVariant, hideBorder, compact, imageUrl, imagePosition, and so on. Every team’s slightly different card became one more prop. The component’s code was a thicket of conditionals, and the docs page listed props nobody could keep in their head. This is prop explosion, and every design system meets it.
Configuration versus composition
A configured component receives data and decides the layout itself. A composed component offers pieces, and the consumer arranges them.
Here is the same shipment card, composed:
<Card>
<Card.Header>
<Card.Title>Shipment NW-4471</Card.Title>
<Badge tone="info">In transit</Badge>
<ShipmentMenu shipmentId="NW-4471" />
</Card.Header>
<Card.Body>Rotterdam to Hamburg · 2 pallets</Card.Body>
<Card.Footer>
<Button size="sm">Track</Button>
<Button size="sm" variant="primary">Assign driver</Button>
</Card.Footer>
</Card>The card never needed a footerSecondaryAction prop because Card.Footer is a slot that accepts any actions. The menu is the team’s own component, placed where the header allows. Yet the system still owns spacing, borders, typography and responsive behavior of each piece. Teams get flexibility in what goes where; the system keeps control of how each piece looks.
Sharing state between the pieces
Static pieces like Card only need layout. Interactive ones like Tabs need shared state: the selected tab, and which panel to show. The robust way to share it is context:
import { createContext, use, useId, useState } from 'react';
const TabsContext = createContext<{
selected: string;
select: (value: string) => void;
baseId: string;
} | null>(null);
function useTabs() {
const ctx = use(TabsContext);
if (!ctx) throw new Error('Tabs.* must be used inside <Tabs>');
return ctx;
}
export function Tabs({ defaultValue, children }: { defaultValue: string; children: React.ReactNode }) {
const [selected, select] = useState(defaultValue);
const baseId = useId();
return <TabsContext value={{ selected, select, baseId }}>{children}</TabsContext>;
}
Tabs.Trigger = function Trigger({ value, children }: { value: string; children: React.ReactNode }) {
const { selected, select, baseId } = useTabs();
return (
<button
type="button"
role="tab"
id={`${baseId}-tab-${value}`}
aria-selected={selected === value}
aria-controls={`${baseId}-panel-${value}`}
onClick={() => select(value)}
>
{children}
</button>
);
};
// Tabs.List (role="tablist") and Tabs.Panel (role="tabpanel") follow the same idea.A trigger reads the shared state wherever it sits in the tree: inside a tooltip, a layout div, or a component the team wrote. In React 19 you render the context directly as a provider (<TabsContext value={…}>) and read it with use. The ARIA roles come from the WAI-ARIA tabs pattern, which you’ll meet properly in the next lesson; keyboard handling is left out here to keep the example short.
Composition has costs too
Composition isn’t free. Consumers write more markup, and they can arrange pieces in ways you didn’t intend, such as two footers or a title in the body. Northwind handles this three ways. Each piece is constrained: Card.Footer lays out actions and nothing else looks right in it. Common arrangements ship as documented recipes that teams copy. And a few very common combinations become small configured wrappers built from the composed pieces, such as a ShipmentSummaryCard with four props, so the 80% case stays short while the 20% case stays possible.
Migrating away from a configured component
You rarely get to start fresh. ShipmentCard was used on 60 screens when we redesigned it. We didn’t delete it; we rebuilt it on top of the new pieces, so its 23 props now render Card.Header, Card.Body and Card.Footer internally. Every existing screen kept working and picked up the new styling for free. Then we marked ShipmentCard deprecated, documented the composed equivalent of each common prop combination, and migrated screens team by team. Building the old API from the new pieces is what made the migration boring, which is the highest compliment a migration can get.
When configuration is right
Not every component should be compound. A status Badge has a tone and a label; an Avatar has an image, a name and a size. Their variations are few and closed, so props describe them completely and composition would only add ceremony.
A practical rule from Northwind’s API reviews: when props start describing layout (imagePosition, footerAlign) or content structure (menuItems, footerText), the component wants slots or pieces. When props describe appearance or state (tone, size, disabled), configuration is fine.
In the exercise, you judge a Dialog API proposal with these rules. Next, you make sure every one of these pieces works with a keyboard and a screen reader, by default.
Key takeaways
- When every new use case adds a prop, the component is configured from the outside and will keep growing; composition lets consumers arrange pieces instead.
- Compound components such as
Card.HeaderandTabs.Triggershare state through context, not by inspecting children. - The system still owns each piece’s look and behavior, so composition adds flexibility without giving up consistency.
- Small, closed components like Badge or Avatar are fine with configuration; reach for composition when props start describing layout or content.