Children and compound components
How React components compose with each other, passing JSX through children, slots, and the shadcn-style compound families you will build the rest of your UI from.
In the last lesson you typed the props contract: what a component accepts as configuration. This lesson is about the other half of that contract, the content. A component’s most powerful input isn’t a string or a boolean; it’s other JSX.
To see why that matters, start from a problem. You build a <Card> that needs a title, a body, and a footer, so you give it three props for those:
<Card title="Acme Inc." body={<p>Renews May 1.</p>} footer={<Button>Manage</Button>} />That works. Then design wants a badge in the corner, then an action menu, then a divider. Each one becomes a new optional prop, badge, action, divider, and the call site stops reading like UI and starts reading like a configuration object you have to decode line by line. A prop list that keeps growing is the sign you’ve outgrown this approach. The fix is to drop the props entirely: you let the consumer hand the card its content as nested JSX, and the card decides where each piece goes.
<Card> <CardHeader> <CardTitle>Acme Inc.</CardTitle> </CardHeader> <CardContent>Renews May 1.</CardContent> <CardFooter> <Button>Manage</Button> </CardFooter></Card>That reads like HTML because it has the same shape as HTML: nested elements, each in its place. By the end of this lesson you’ll be able to decide between a prop and a slot, build that compound <Card> family from scratch, and avoid a conditional-rendering bug that ships to production all the time. This isn’t only a teaching example. The family above is the exact shape every shadcn component takes, the one you’ll import and read verbatim when we reach the shadcn library a few chapters from now. You’re learning to write the thing you’ll later consume.
children is the universal content slot
Section titled “children is the universal content slot”This builds on something you already do without thinking. In the last lesson, props arrived as one object and you destructured the names you wanted right in the parameter. children works the same way: it’s just another prop, and you pull it out like any other.
({ children }: { children: ReactNode }) => { /* ... */ };The only new idea is where the value comes from. Every other prop is passed as an attribute, like variant="primary" or disabled. children is passed as whatever sits between the opening and closing tags. When the consumer writes <Card>Anything here</Card>, that text, along with any JSX in any amount, arrives as the children prop. React fills it in for you, so you never pass it by name.
Wire it into the running <Card> from the last lesson. The cn() merge and the ...rest forwarding stay exactly as they were:
type Props = ComponentProps<'div'> & { children: ReactNode;};
const Card = ({ children, className, ...rest }: Props) => ( <div className={cn('rounded-lg border bg-card p-6', className)} {...rest}> {children} </div>);Three things about this block are worth spelling out. First, children comes in through the props object and gets placed by {children} inside your JSX. The component decides where the content lands. Here it’s wrapped in the bordered, padded <div>, but you could put markup before it, after it, or around it. The consumer brings the content, and you frame it.
Second, the type. children is typed ReactNode, the deliberately broad “anything React can render” type. It accepts JSX elements, plain strings, numbers, arrays of those, fragments, portals, and, the part that matters in a minute, null, undefined, and boolean, all three of which render as nothing. That breadth is deliberate, not sloppiness. It’s why {condition && <Thing />} and {items.map(...)} both work when you drop them into JSX: a false renders nothing, and an array renders each element. The type is broad on purpose, so that everything you’d naturally want to render is already legal.
Third, there’s a narrower cousin you’ll meet in older code or in a teammate’s PR, called ReactElement . Where ReactNode is “anything renderable,” ReactElement is “a single JSX element” and nothing else. It’s the wrong default almost every time, because if you type children: ReactElement, your component rejects a string, two elements, or a fragment the moment someone passes one. You reach for it only in the rare case where a component must inspect or clone its one child, and even then modern React has a cleaner, typed way to do that, which is what the next lesson’s asChild is for. The rule from the code conventions is worth memorizing: children are ReactNode, never JSX.Element or ReactElement.
Compound components: regions as JSX, not props
Section titled “Compound components: regions as JSX, not props”A <Card> that just wraps children is fine when the card holds one undivided block of content. But real cards have structure: a header, a body, a footer, maybe an action in the top-right corner. The question is how the consumer puts content into each of those regions. The answer this chapter opened on, one prop per region, is the obvious one but not the best one. The better answer is a compound component. The card ships not as one component but as a small, tightly coupled family, and the consumer composes that family with JSX.
This is the core shadcn pattern, so it’s worth learning the exact family you’ll be importing later. The current shadcn <Card> looks like this:
DirectoryCard the outer container
DirectoryCardHeader the top region
- CardTitle a semantic heading
- CardDescription muted subtext
- CardAction the top-right slot, a button or a badge
- CardContent the body
- CardFooter the bottom region
Read that tree as a contract, not as files: every one of those lives in a single card.tsx. Card is the container. Inside it, CardHeader groups the top region, and the header itself holds a CardTitle (a real heading), an optional CardDescription, and a CardAction for whatever sits top-right, such as a button, a badge, or a menu trigger. CardContent is the body, and CardFooter is the bottom strip. The consumer picks the parts they need, puts them in the order they want, and skips the rest.
To see why this beats a prop per region, look at the two designs side by side, building the same card both ways:
<Card title="Acme Inc." description="Pro plan" body={<p>Renews May 1.</p>} action={<Button size="sm">Upgrade</Button>} footer={<Button>Manage</Button>}/>Every region is a prop, so the prop list grows without end. The card has to enumerate every region it might ever hold, and the moment design wants a new one, say a badge or a divider, you edit the component to add another optional prop and another branch in its body. The call site becomes a configuration object you have to read top to bottom.
<Card> <CardHeader> <CardTitle>Acme Inc.</CardTitle> <CardDescription>Pro plan</CardDescription> <CardAction> <Button size="sm">Upgrade</Button> </CardAction> </CardHeader> <CardContent>Renews May 1.</CardContent> <CardFooter> <Button>Manage</Button> </CardFooter></Card>Every region is a subcomponent, so a new region is a new export, not a new prop. The consumer places real JSX in each slot, controls the order, and omits what they don’t need. A new region tomorrow is one more thin wrapper in card.tsx, and the existing call sites never change. It reads like the markup it produces.
The shift is small but it changes everything. In the first design, the component owns the list of possible regions, so every new region is the component’s problem. In the second, the consumer owns the composition, and the component just supplies the building blocks. That’s the whole idea behind “composition over configuration”: once a component is growing a prop for every region it might hold, stop adding props and start handing out pieces.
So what does a “piece” actually look like? Each member of the family is ordinary: an L1-style typed component that wraps one element, owns its own classes, and forwards className and ...rest. There’s nothing new to learn for each part, because every part repeats the same structure. Here’s the family in one file:
const Card = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('rounded-lg border bg-card p-6', className)} {...rest} />);
const CardHeader = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('flex items-start justify-between gap-4', className)} {...rest} />);
const CardTitle = ({ className, ...rest }: ComponentProps<'h3'>) => ( <h3 className={cn('font-semibold leading-none', className)} {...rest} />);
// CardDescription, CardContent, CardFooter follow the same shape.
const CardAction = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('shrink-0', className)} {...rest} />);
export { Card, CardHeader, CardTitle, CardAction };This one file exports a set of components, not one. That’s the sanctioned exception to “one component per file” from the last lesson: a tightly coupled family, where you never use one piece on its own, ships together.
const Card = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('rounded-lg border bg-card p-6', className)} {...rest} />);
const CardHeader = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('flex items-start justify-between gap-4', className)} {...rest} />);
const CardTitle = ({ className, ...rest }: ComponentProps<'h3'>) => ( <h3 className={cn('font-semibold leading-none', className)} {...rest} />);
// CardDescription, CardContent, CardFooter follow the same shape.
const CardAction = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('shrink-0', className)} {...rest} />);
export { Card, CardHeader, CardTitle, CardAction };The container is the exact structure from the last lesson: cn(base, className), with ...rest spread onto a plain <div>. Notice there’s no children in the destructure here. children rides along inside ...rest and lands on the <div> automatically, so you don’t have to name it unless you want to place it deliberately.
const Card = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('rounded-lg border bg-card p-6', className)} {...rest} />);
const CardHeader = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('flex items-start justify-between gap-4', className)} {...rest} />);
const CardTitle = ({ className, ...rest }: ComponentProps<'h3'>) => ( <h3 className={cn('font-semibold leading-none', className)} {...rest} />);
// CardDescription, CardContent, CardFooter follow the same shape.
const CardAction = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('shrink-0', className)} {...rest} />);
export { Card, CardHeader, CardTitle, CardAction };A subcomponent is a thin wrapper. It owns its classes (here, the flex layout that pushes the action to the right), accepts its own className, and spreads ...rest. Each part styles its region and nothing else.
const Card = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('rounded-lg border bg-card p-6', className)} {...rest} />);
const CardHeader = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('flex items-start justify-between gap-4', className)} {...rest} />);
const CardTitle = ({ className, ...rest }: ComponentProps<'h3'>) => ( <h3 className={cn('font-semibold leading-none', className)} {...rest} />);
// CardDescription, CardContent, CardFooter follow the same shape.
const CardAction = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('shrink-0', className)} {...rest} />);
export { Card, CardHeader, CardTitle, CardAction };CardTitle renders a semantic <h3>, not a styled <div>. The accessibility lives in the subcomponent, so the consumer writes <CardTitle> and gets a real heading for free. (We’ll rely on this idea heavily in the shadcn chapter.)
const Card = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('rounded-lg border bg-card p-6', className)} {...rest} />);
const CardHeader = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('flex items-start justify-between gap-4', className)} {...rest} />);
const CardTitle = ({ className, ...rest }: ComponentProps<'h3'>) => ( <h3 className={cn('font-semibold leading-none', className)} {...rest} />);
// CardDescription, CardContent, CardFooter follow the same shape.
const CardAction = ({ className, ...rest }: ComponentProps<'div'>) => ( <div className={cn('shrink-0', className)} {...rest} />);
export { Card, CardHeader, CardTitle, CardAction };Every part is its own L1-typed component, and ComponentProps<'div'> pulls in every native attribute. CardAction shows the whole pattern most clearly: a brand-new region, the top-right slot, is a new export, never a new prop on <Card>.
Notice that across those five steps there was no new mechanism. Every part is a function you already know how to write from the last lesson. The pattern lives entirely in the packaging: a coordinated set of thin wrappers, exported together, each owning one region’s styling. Compound components can feel sophisticated, but they turn out to be among the simplest things in the chapter.
Why is this worth it? There are three concrete payoffs, each tied to an edit you can picture making:
- It grows by adding a new part, not by editing the old one. If design wants a
CardBadgenext month, you write one more eight-line wrapper and export it. Every existing<Card>in the codebase keeps working untouched. The prop-per-region card would force you to edit the component and would put every call site at risk. - The consumer controls order and presence. Want the footer above the content for one card, or no header at all? Reorder the JSX and drop the part you don’t want. The component never dictated a fixed layout, so there’s nothing to work around.
- Each part takes a
className. Because every wrapper forwardsclassNamethroughcn(), the consumer can restyle one region in place:<CardHeader className="bg-muted">tints just that header. There’s no prop to add and no need to fork the component. The styling escape hatch from the last lesson is open on every part.
One caveat is worth fixing in your mental model now, so you don’t go looking for a guarantee that isn’t there. Nothing stops a consumer from rendering <CardFooter> outside of a <Card>. They’re just exported components. What couples them is convention and documentation, not the type system, so TypeScript will not error if you misuse them. That’s a real limitation of the pattern, and the right response is to document the family so consumers know the parts belong together. You might wonder whether the parts should share state through a context that links them. For a styling-only family like this <Card>, the answer is no: these parts only carry classes, so reaching for context here would be over-engineering. Context-linked compound components are a real pattern, just a later one.
Step back and notice the bigger picture. This shape, a family of thin, composable parts, is what every modern component library is built on: shadcn, Radix, and Ariakit. shadcn relies on it so heavily that it now ships these composition trees right in its docs, so that humans (and coding agents) assemble the parts in the right shape without forgetting a wrapper. This isn’t a quirky technique. It’s the default form of a 2026 component library, and you’re learning it by writing the thing you’ll soon import unmodified.
Now it’s your turn to build it. The next exercise hands you a half-wired <Card> family, and your job is to make each part forward its content and classes correctly.
Finish the Card family. CardHeader and CardFooter are stubbed — they render an empty <div> and drop everything passed to them. Wire each one like the working Card and CardTitle above it: merge the caller's className through cn(), and spread ...rest onto the <div> so children land inside. Get all four checks green.
Reveal the wired family
const CardHeader = ({ className, ...rest }) => ( <div className={cn('flex items-start justify-between gap-4', className)} {...rest} />);
const CardFooter = ({ className, ...rest }) => ( <div className={cn('flex items-center', className)} {...rest} />);Both stubs take the same shape as the working Card and CardTitle: destructure className out, hand it to cn() so the base classes and the caller’s class merge into one string, then spread ...rest onto the <div>. The piece that does the real work is ...rest, because children is in rest, so spreading it onto the <div> is what places the consumer’s content inside. That single spread is why <CardHeader className="bg-muted"><CardTitle>Acme Inc.</CardTitle></CardHeader> now tints the header and shows the title: the class merged, and the title rode in on ...rest. The base classes here are only illustrative; all that was required was the cn(..., className) merge and the spread.
<Card> <CardHeader className="bg-muted"> <CardTitle>Acme Inc.</CardTitle> </CardHeader> <p>Renews May 1.</p> <CardFooter> <button>Manage</button> </CardFooter></Card>One region? Reach for a prop, not a subcomponent
Section titled “One region? Reach for a prop, not a subcomponent”Compound components are often the answer, but not always. Reaching for them everywhere is the opposite mistake to never discovering them, and just as costly. A whole class of components own exactly one named region, and for those a subcomponent is pure ceremony.
Take the <Button> from the last lesson. A common need is an icon before the label: a trash icon on a delete button, a plus on a create button. You could invent a subcomponent for it:
<Button> <ButtonIcon> <TrashIcon /> </ButtonIcon> Delete</Button>A whole subcomponent for a single fixed slot. A button has exactly one icon position. Adding a <ButtonIcon> family member to fill it applies the compound pattern where there’s nothing to compose: ceremony with no payoff.
<Button leftIcon={<TrashIcon />}>Delete</Button>One region, so one ReactNode prop. The button owns a single icon slot, so a named prop expresses it exactly: readable at the call site, and the consumer still hands in any JSX they like.
This is prop-as-slot: a named region passed as a ReactNode prop. It’s the same idea as children, where the consumer hands you JSX, except the region is named, so the consumer (and the types) know exactly which slot it fills. Wiring it onto the <Button> is a one-line addition to the component you already have:
type Props = ComponentProps<'button'> & { variant?: 'primary' | 'destructive' | 'ghost'; size?: 'sm' | 'md' | 'lg'; leftIcon?: ReactNode;};
const Button = ({ variant = 'primary', size = 'md', leftIcon, children, className, ...rest}: Props) => ( <button className={cn(buttonClasses({ variant, size }), className)} {...rest}> {leftIcon} {children} </button>);(buttonClasses is the stand-in from the last lesson for “variant + size → class string.” Don’t build it.)
Notice that both forms, children and leftIcon, are typed ReactNode, and both forward className through cn(). They are the same tool. The only thing that differs is the number of regions: children is the unnamed catch-all, and leftIcon is a named single slot. So how do you decide, in the moment, which to reach for? There’s a clean rule, and it’s the central idea of the whole lesson:
Zero or one named region → prop-as-slot (or just
children). Two or more → compound.
There’s a corollary that overrides the count: if the consumer needs to reorder, omit, or independently restyle the regions, that pushes you toward compound even at one region, because those three freedoms are exactly what compound components give and props don’t.
A rule sticks better once you’ve used it, so put this one to work. Walk the decision tree below for a handful of real components. At each step, answer the question, and the walker lands you on a recommendation. Try to predict each verdict before you click it.
The component is a pure wrapper around one undivided block of content, like a <Badge> around its text.
Type it children: ReactNode and place {children} wherever it belongs in your markup.
No named slots needed.
Exactly one named region, fixed in place, like a <Tooltip content={...}> or a <Button leftIcon={...}>.
A named ReactNode prop says precisely which slot it fills and reads cleanly at the call site,
while the consumer still hands in any JSX they like.
Two or more regions, or one region the consumer must reorder, omit, or restyle independently.
A <Dialog> with header, body, and footer, or a <Toolbar> with arbitrary groups.
Ship a coordinated set of thin subcomponents and let JSX do the composing.
That walker captures the whole mental model. Most beginners reach for props on instinct and never discover compound components; a few, having just learned compound components, force every component into a family. The rule keeps you out of both traps: count the regions, check the three freedoms, and pick the tool.
Conditional rendering and the 0-falsy trap
Section titled “Conditional rendering and the 0-falsy trap”You’ll rarely render the same content unconditionally. A panel shows only when it’s open, an error banner replaces the form when something breaks, and a list appears only when it has items. React doesn’t have a special syntax for this. It falls straight out of the JSX rules you already met, where booleans, null, and undefined render as nothing. That’s the payoff of ReactNode being so broad: the two everyday patterns are just JavaScript expressions inside a {}.
{isOpen && <Panel />}
{isError ? <Alert /> : <Content />}The first is the on/off form: {isOpen && <Panel />}. When isOpen is true, && evaluates to the right-hand side and the <Panel /> renders; when it’s false, the whole expression is false, which renders as nothing. That only works because false is a legal, render-as-nothing member of ReactNode, so the breadth of the type is exactly what makes this safe. The second is the pick-one form: {isError ? <Alert /> : <Content />} renders one branch or the other. Reach for && when the choice is “show this or show nothing,” and the ternary when it’s “show this or show that.”
Now for the bug. It is the most common conditional-render mistake shipped to production, and it hides inside the reasonable-looking && form:
{count && <List items={items} />}It reads fine: “if there’s a count, render the list.” But run it when count is 0, and React prints a literal 0 to the page, a stray zero sitting next to an empty list where the user can see it. The mechanism is worth understanding rather than memorizing. 0 is falsy, so && short-circuits and the whole expression evaluates to 0: not false, the number 0. A number is a perfectly renderable member of ReactNode, so React does exactly what you told it and renders the 0. (The empty string '' does the same thing, for the same reason.) The && idiom is safe with a boolean on its left, because false renders nothing, but a number on the left can short-circuit to a renderable value.
Before the fix, predict the bug yourself:
messageCount is 0. The badge is meant to appear only when there are unread messages. What actually lands on the page here?
<span>{messageCount && <Badge>New</Badge>}</span><span> — 0 is falsy, so the whole expression drops out and nothing renders.<span> containing the digit 0 — the user sees a stray 0 where the badge should have been.<Badge>New</Badge> — a falsy left side is ignored and the right side renders anyway.&& can’t combine a number with a JSX element.messageCount && <Badge>New</Badge> short-circuits on the falsy 0, so the expression’s value is the number 0 — not false, not nothing. Numbers are renderable ReactNode, so React dutifully prints 0 inside the span. The <Badge> never renders, but the 0 does. Put a real boolean on the left — messageCount > 0 && … — and it disappears.The fix is to put a real boolean on the left of &&, never a bare number or string. There are three forms, all from the code conventions, so pick whichever reads best at the call site:
{count > 0 && <List items={items} />}
{Boolean(count) && <List items={items} />}
{value != null && <Field value={value} />}The first compares to a number, producing a real true/false, and reads cleanest when you genuinely mean “more than zero.” Boolean(count) coerces explicitly when any non-zero count should show. And value != null is the one to reach for with a nullable value: it catches both null and undefined while letting through legitimate falsy values like 0 or ''. The rule underneath all three is the same: the left side of && in JSX must be a real boolean. Build that habit and the bug never appears.
Fragments group without a wrapper
Section titled “Fragments group without a wrapper”A component must return one parent element; you can’t return two siblings side by side. The quick fix is to wrap them in a <div>, and most of the time that’s harmless. But sometimes that extra <div> causes problems. It can break a flex or grid layout that expected direct children, or it can produce invalid HTML where the parent demands specific child tags. For those cases React gives you a wrapper that emits no DOM node at all, the fragment .
The clearest example is a definition list. A <dl> expects bare <dt>/<dd> pairs as its children, and slipping a <div> between them makes the HTML invalid. So a component that renders one row of the list has to return two siblings with no wrapper:
const PlanRow = ({ label, value }: { label: string; value: string }) => ( <> <dt className="text-muted-foreground">{label}</dt> <dd className="font-medium">{value}</dd> </>);Those <> and </> are the fragment shorthand, an empty tag that groups its children and renders nothing of its own. The two <dt>/<dd> elements come out as direct, valid children of whatever <dl> the consumer drops PlanRow into, with no wrapper, no invalid markup, and no broken layout. This is the form you’ll reach for daily.
There’s one case where the shorthand can’t be used: when the fragment is a list item. If you map over a list and each iteration emits two sibling elements, React needs a key on each, which is how it tracks which item is which, and you can’t put a key on <>. There you switch to the longhand <Fragment>:
{plans.map((plan) => ( <Fragment key={plan.id}> <dt>{plan.label}</dt> <dd>{plan.price}</dd> </Fragment>))}We’ll get to why lists need a key, and what React does with it, in the next chapter on how components render. For now, two facts are enough. When a fragment is part of a mapped list, use <Fragment key={...}> instead of <>. Outside of lists, fragments don’t take a key at all, so don’t add one to a fragment that isn’t a list item, since it does nothing there.
Children as a function: the render prop you’ll recognize but rarely write
Section titled “Children as a function: the render prop you’ll recognize but rarely write”There’s one more shape children can take, and the honest framing up front is this: you will read this, and you will almost never write it. It’s here so it doesn’t surprise you in someone else’s code, not because you’ll reach for it yourself.
So far children has been content, JSX the component places. But children can also be a function. The component owns some value, such as state, a subscription, or the result of a fetch, and instead of rendering content directly, it calls children with that value and lets the consumer decide what to render:
<DataLoader url="/api/invoices"> {(invoices) => <InvoiceList items={invoices} />}</DataLoader>Here children isn’t JSX. It’s (invoices) => <InvoiceList items={invoices} />, a function. DataLoader does the loading, then calls that function with the data, handing control of the render back to the consumer. Typed, children here is (data: T) => ReactNode, a function that takes the component’s value and returns something renderable. The name for this is a render prop .
It’s a clever pattern, and it is not deprecated, but in 2026 it’s something to recognize, not a daily tool. The reason is that nearly every case that historically reached for a render prop is now a custom hook. DataLoader as a render-prop component is the old shape; const invoices = useInvoices() is the modern one, with the same capability, no nesting, and no function-as-child. (We build custom hooks properly a few chapters on.) Render props survive only in the narrow case where a component also renders chrome around the consumer’s output, such as a layout or a boundary, and even those are few. The takeaway is one sentence: recognize it when you read it, and reach for a custom hook before you ever write one.
There’s an even rarer cousin worth a mention. React ships two utilities, Children.map and Children.toArray, for iterating a component’s children. The 2026 senior instinct is to not iterate children: if a component needs to know about a list of things, expose a data prop and map over the data, not the JSX. Children.toArray exists for the genuine edge case where a component must inspect its children and wants stable keys while doing it, such as a custom <Tabs> reading its <Tab> elements to build a tab strip. Know it’s there, and treat it as rare and intentional. One trap is worth naming explicitly: do not reach for cloneElement or Children.map to inject props into children. That’s the old hand-rolled way, and the next lesson’s asChild does exactly that with a typed, sanctioned contract.
Composition is the first answer to prop drilling
Section titled “Composition is the first answer to prop drilling”One last idea, as a preview. It points at a problem you’ll solve properly later, but it belongs here, because composition is the first tool you should reach for.
Picture a layout nested a few levels deep: <Layout> renders <Page>, which renders <Header>, which renders <Toolbar>. Only the <Toolbar> at the very bottom needs the current user, to show their avatar, say. But the user enters at the top, so you thread it down: <Layout> takes a user prop and passes it to <Page>, which takes it and passes it to <Header>, which takes it and passes it to <Toolbar>. Three components in the middle accept and forward a prop they have no use for, purely to relay it. That’s prop drilling, and every one of those middle components now has user in its signature for no reason of its own.
There’s a fix that needs no new tool, just the composition you learned today. Instead of <Layout> creating the toolbar deep inside, let it accept the finished toolbar as a slot, through children or a named ReactNode prop. Now the consumer wires user to <Toolbar user={user} /> at the top, at the call site, and hands the assembled element down. The middle layers pass an opaque ReactNode through, so they never see user, never name it, and never know it exists.
user is threaded through every layer, even the ones that don’t use it. Each middle box accepts user and passes it on, carrying a prop it has no use for itself.
user is wired into <Toolbar user={user} /> at the call site and handed to <Layout> as a slot. The middle layers pass an opaque children through; user jumps straight to the bottom, never touching Page or Header.
The idea to hold onto is this: composition makes the component that needs the prop and the component that has the prop into siblings at the call site. They meet where the user already is, so nothing has to drill.
To be clear about the limits of this preview: it isn’t the whole story of where state lives or how to share it widely. Those are real topics we treat properly later, including where state should live and Context as the tool for genuinely cross-cutting values. The point to take now is the order you reach for tools: when a value has to reach somewhere deep, try composition first. Prop drilling is not automatically a bug that demands Context, and reaching for Context too early is its own warning sign. Composition clears away a surprising amount of drilling for free, with tools you already have. Keep it at the front of your mind, and we’ll add the heavier tools later.
What you can do now
Section titled “What you can do now”You came in able to type what a component accepts; you leave able to type what it contains. The throughline of the whole lesson was one question asked over and over: does this region deserve a prop, or a slot? The answer is a rule you can now apply on sight: zero or one region, a prop or children; two or more, a compound family. You can build that family, the shadcn <Card> you’ll soon import for real, out of nothing but the thin typed wrappers from the last lesson. You can render content conditionally without printing a stray 0. And you know that when a prop has to travel deep, composition is the first thing to try.
The next lesson takes the same <Button> and <Card> and asks a different question: what if a <Button> needs to render as a link? That’s polymorphism, and it’s where asChild, Slot, and cva come in, the last pieces that make these components the real shadcn shape.