Stop configuring components, start composing them
The prop surface of a component is a bet on how it will be used. Composition-first design keeps that bet small.
Every component starts as a good idea. You build a Button. It takes a label and an onClick. Clean.
Then someone needs an icon. Then a loading state. Then a tooltip. Then a badge. Then the icon on the right instead of the left. Then two icons. Then they need a custom icon that is not in the icon library.
By then, the Button has fourteen props, an internal switch statement, and a README with a "do not use deprecated props" section.
This is the configuration trap. The fix is composition.
#What composition means in practice
Instead of passing data down to a component that renders it, you pass components in. The container handles layout and interaction; the consumer handles content.
// configuration: the component decides everything
<Button
label="Save"
iconLeft="save"
iconRight="arrow-right"
loading={isSaving}
loadingLabel="Saving..."
/>
// composition: the component handles less
<Button onClick={save} disabled={isSaving}>
{isSaving ? <Spinner /> : <SaveIcon />}
{isSaving ? 'Saving…' : 'Save'}
</Button>The second version is longer at the call site. It is also shorter everywhere else, because you deleted the fourteen props.
#The children slot is the base case
children is the most underused prop in React. If your component renders some variation of "a heading, then some content, then some actions," the actions are almost certainly children:
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<article className={styles.card}>
<h2 className={styles.title}>{title}</h2>
<div className={styles.body}>{children}</div>
</article>
);
}Now Card never needs a footer prop, an actions prop, or an image prop. The consumer renders what they need.
#Named slots for multiple injection points
When children is not specific enough, use explicit named props that accept nodes:
type DialogProps = {
title: React.ReactNode;
footer: React.ReactNode;
children: React.ReactNode;
};
function Dialog({ title, footer, children }: DialogProps) {
return (
<div role="dialog">
<header>{title}</header>
<main>{children}</main>
<footer>{footer}</footer>
</div>
);
}<Dialog
title={<h2>Confirm deletion</h2>}
footer={
<>
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
<Button variant="danger" onClick={onConfirm}>Delete</Button>
</>
}
>
<p>This action cannot be undone.</p>
</Dialog>The Dialog never needs to know what buttons exist, what they do, or how many of them there are.
#The compound component pattern
For tightly related components that share implicit state, compound components give you composition without prop drilling:
function Tabs({ children, defaultValue }: TabsProps) {
const [active, setActive] = useState(defaultValue);
return (
<TabsContext.Provider value={{ active, setActive }}>
{children}
</TabsContext.Provider>
);
}
Tabs.List = function TabsList({ children }: { children: React.ReactNode }) {
return <div role="tablist">{children}</div>;
};
Tabs.Tab = function Tab({ value, children }: TabProps) {
const { active, setActive } = useContext(TabsContext);
return (
<button
role="tab"
aria-selected={active === value}
onClick={() => setActive(value)}
>
{children}
</button>
);
};<Tabs defaultValue="preview">
<Tabs.List>
<Tabs.Tab value="preview">Preview</Tabs.Tab>
<Tabs.Tab value="code">Code</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="preview"><Preview /></Tabs.Panel>
<Tabs.Panel value="code"><CodeBlock /></Tabs.Panel>
</Tabs>The external API is declarative. The shared state is internal. Adding a third tab requires one line, not a new prop.
#When configuration is correct
Not everything should be composition. A <Badge variant="success"> is the right call for a fixed set of visual variants — that is configuration serving a real constraint. Configuration earns its place when the variants are closed and stable.
The smell is open-ended configuration: when you keep adding variants to cover use cases you did not predict. That is the moment to ask whether the caller should be deciding.
The fewer decisions a component makes for the caller, the longer the component survives the codebase.
Composition is not a rule. It is a question: who should decide? When the answer is the caller, expose a slot and step back.