Children and slots
The children prop and named JSX slots let callers compose content while the component owns structure — inverting control to kill prop explosion and keep components open, as long as the shape is actually variable.
Here is a Card you have written a hundred times. It started with a title and a body. Then a footer, a badge, an icon, a “dismissable” flag, an actions row, a variant — and now its props interface has fourteen entries and every new design request adds a fifteenth. Each prop is a tiny config language you invented, and the component is a switchboard of {badge && <Badge>…</Badge>} that nobody enjoys editing.
There is a different move, and it is the most important one in React: instead of describing the content with props, you let the caller pass the content in. That move has a name — composition via children — and once you see it, half of your “this component has too many props” problems dissolve.
After this lesson you can use the children prop to let callers compose arbitrary content into a component; reach for named slots (JSX passed via props like header / footer) when a layout has several distinct regions; explain why composition inverts control — the parent decides what content, the component decides structure — and why that kills prop explosion; and name the failure mode: over-slotting a component whose shape is fixed and simple, where slots add ceremony and buy no flexibility.
children is a normal prop that happens to hold JSX — so the caller, not the component, supplies the content. Anything you nest between a component’s tags arrives as props.children. The component renders the chrome (border, padding, shadow) and drops children into the hole. It never has to know what is inside.
function Card({ children }: { children: React.ReactNode }) {
return <section className="card">{children}</section>;
}
// the caller composes freely — Card knows nothing about a chart or a form
<Card>
<h3>Revenue</h3>
<RevenueChart range="30d" />
</Card>React.ReactNode is the right type for “any renderable content”: elements, strings, numbers, fragments, arrays, null. You don’t enumerate what can go inside — that’s the whole point.
The alternative — a prop for every piece of content — is “prop explosion”, and it gets worse with every design request. Watch a Card try to describe its content through configuration. Each new requirement is a new prop, a new branch, and a new way for two props to contradict each other.
// rigid: the component owns WHAT the content is, via 8 config props
type CardProps = {
title: string;
subtitle?: string;
bodyText?: string;
imageUrl?: string;
badgeText?: string;
footerText?: string;
ctaLabel?: string;
onCta?: () => void;
};
function Card(p: CardProps) {
return (
<section className="card">
{p.imageUrl && <img src={p.imageUrl} alt="" />}
<h3>{p.title}</h3>
{p.subtitle && <p className="sub">{p.subtitle}</p>}
{p.badgeText && <span className="badge">{p.badgeText}</span>}
{p.bodyText && <p>{p.bodyText}</p>}
{p.footerText && <footer>{p.footerText}</footer>}
{p.ctaLabel && <button onClick={p.onCta}>{p.ctaLabel}</button>}
</section>
);
}The first time a designer wants two badges, or a body that contains a <Chart> instead of text, or a CTA that is a link — you add badgeText2, bodyNode, ctaHref, and the interface metastasizes. The component is closed: every kind of content must be anticipated and encoded in advance.
Replace the content props with children and the component reopens — the prop count collapses while flexibility goes up. Keep only the props that are genuinely the component’s own concern (its variant, its behavior), and let composition carry the content.
type CardProps = {
variant?: "default" | "outlined"; // the card's own concern
children: React.ReactNode; // the content is the caller's
};
function Card({ variant = "default", children }: CardProps) {
return <section className={`card card--${variant}`}>{children}</section>;
}
// any content, composed by the caller — no new prop ever needed
<Card variant="outlined">
<CardImage src="/r.png" />
<h3>Revenue <Badge>live</Badge> <Badge>beta</Badge></h3>
<RevenueChart range="30d" />
<a href="/reports">Open report</a>
</Card>Two badges, a chart in the body, a link CTA — none of it required a change to Card. The component went from eight content props to zero, and it can now hold content its author never imagined. That is the payoff of an open component.
When a layout has several distinct regions, use named slots: pass JSX through named props like header and footer. A single children is perfect when content is one stream. But a dialog or a page shell has fixed positions — a header at top, a footer at bottom, the main body between — and you want the component to place each region, while the caller fills it. Named slots are just props typed as React.ReactNode.
type PanelProps = {
header?: React.ReactNode; // a slot: caller supplies JSX, Panel positions it
footer?: React.ReactNode; // another region
children: React.ReactNode; // the main body
};
function Panel({ header, footer, children }: PanelProps) {
return (
<section className="panel">
{header && <header className="panel__head">{header}</header>}
<div className="panel__body">{children}</div>
{footer && <footer className="panel__foot">{footer}</footer>}
</section>
);
}
<Panel
header={<h2>Settings <StatusDot ok /></h2>}
footer={<><Button>Cancel</Button><Button variant="primary">Save</Button></>}
>
<SettingsForm />
</Panel>The caller still decides what each region contains (a heading with a status dot, two buttons); Panel decides where the regions go. Slots scale to multi-region layouts that a single children can’t express without the caller knowing the internal markup.
A real Card, before and after — eight config props versus children plus two slots. A team’s card started simple and accreted props as the product grew. Here is the closed version that every change has to edit:
function Card({
title, subtitle, badgeText, bodyText,
imageUrl, footerLeft, footerRight, dismissable, onDismiss,
}: CardProps) {
return (
<section className="card">
{dismissable && <button className="x" onClick={onDismiss}>×</button>}
{imageUrl && <img src={imageUrl} alt="" />}
<header>
<h3>{title}</h3>
{badgeText && <span className="badge">{badgeText}</span>}
{subtitle && <p className="sub">{subtitle}</p>}
</header>
{bodyText && <p>{bodyText}</p>}
<footer>
<span>{footerLeft}</span>
<span>{footerRight}</span>
</footer>
</section>
);
}Now rewrite it so the caller composes the content. The body is one stream (children); the header and footer are distinct regions, so they become named slots. Only dismissable/onDismiss remain props, because dismissal is the card’s own behavior, not content:
type CardProps = {
header?: React.ReactNode;
footer?: React.ReactNode;
onDismiss?: () => void; // behavior stays a prop
children: React.ReactNode;
};
function Card({ header, footer, onDismiss, children }: CardProps) {
return (
<section className="card">
{onDismiss && <button className="x" onClick={onDismiss}>×</button>}
{header && <header className="card__head">{header}</header>}
<div className="card__body">{children}</div>
{footer && <footer className="card__foot">{footer}</footer>}
</section>
);
}
// caller — and now two badges, a chart body, a link footer all "just work"
<Card
onDismiss={close}
header={<><h3>Revenue</h3><Badge>live</Badge><Badge>beta</Badge></>}
footer={<a href="/reports">Open full report →</a>}
>
<RevenueChart range="30d" />
</Card>The component shrank, lost its branch forest, and became reusable for content nobody planned for. The next design request changes the call site, not Card. That is composition inverting control: the parent decides what, Card decides only structure.
▸Why this works
Why does pushing content out to children matter so much beyond a shorter props list? Because it changes who has to anticipate the future. A config-prop component must predict every kind of content it will ever hold; when reality exceeds the prediction, you edit the component (and re-test it, and risk every existing caller). A children-based component predicts nothing — new content is new JSX at the call site, and the component is untouched. Composition trades a closed, all-knowing component for an open one, and “open to extension without modification” is exactly the property that keeps a shared UI component stable as the app grows around it.
▸Common mistake
The failure mode is over-slotting: shattering a component whose shape is fixed and simple into a pile of named slots that buy no flexibility. A <Badge slotIcon slotLabel slotTrailing> for a thing that is always an icon and a word is pure ceremony — three props, three render branches, harder call sites — to express what <Badge icon="check">Done</Badge> (or even <Badge>✓ Done</Badge>) said plainly. Slots earn their keep only when the regions genuinely vary across call sites. If a component has one fixed arrangement and small, predictable content, a couple of plain props (or just children) beat a slot API. Reach for slots when you feel real prop-explosion pressure from multi-region layouts — not preemptively.
A Card currently takes title, subtitle, badgeText, bodyText, and footerText as props, and designers keep asking for new content shapes (a chart in the body, two badges, a link footer). What is the senior refactor?
The children prop is React’s primary composition tool: it is a normal prop holding React.ReactNode, so the caller supplies the content and the component supplies only the structure. That single move replaces “prop explosion” — a config prop and a render branch for every conceivable piece of content — with an open component that holds content its author never imagined. When a layout has several distinct regions, reach for named slots: JSX passed through props like header and footer, which the component positions while the caller fills. Keep as props only what is genuinely the component’s own concern — its variant, its behavior — and let composition carry everything else. The deep idea is inversion of control: the parent decides what, the component decides structure, which is why prop count falls and flexibility rises at the same time. The failure mode is over-slotting a fixed, simple shape — adding slot ceremony where nothing varies. Match slots to real multi-region pressure; otherwise children, or a plain prop, is the senior choice.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.