Children and compound components
How React components compose by passing JSX through children, slots, and the shadcn-style compound families.
Last lesson you wrote a component’s props, its contract: what it accepts as configuration. This lesson is about the other half of that contract, its content. A component’s most powerful input isn’t a string or a boolean; it’s other JSX.
Start from a problem.
You build a <Card> that needs a title, a body, and a footer, so you give it a prop for each:
<Card title="Acme Inc." body={<p>Renews May 1.</p>} footer={<Button>Manage</Button>} />Then design wants a badge in the corner, then an action menu, then a divider. Each becomes another optional prop, and the call site stops reading like UI and starts reading like a configuration object you 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: let the consumer hand the card its content as nested JSX, and let the card place each piece.
<Card> <CardHeader> <CardTitle>Acme Inc.</CardTitle> </CardHeader> <CardContent>Renews May 1.</CardContent> <CardFooter> <Button>Manage</Button> </CardFooter></Card>This reads like HTML because it has HTML’s shape: nested elements, each in its place. It’s also the exact shape every shadcn component takes, the family you’ll import and read verbatim when we reach the shadcn library later.
children is the prop between the tags
Section titled “children is the prop between the tags”In the last lesson, props arrived as one object and you destructured the names you wanted in the parameter. children is just another prop, pulled out the same way.
({ children }: { children: ReactNode }) => { /* ... */ };The one new idea is where its value comes from. Every other prop is passed as an attribute, like variant="primary" or disabled. children is whatever sits between the opening and closing tags. When the consumer writes <Card>Anything here</Card>, that text, plus any JSX in any amount, arrives as the children prop. React fills it in, 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 {children} places it inside your JSX. The component decides where the content lands: here it sits inside the bordered, padded <div>, but you could put markup before, after, or around it.
Second, the type. children is typed ReactNode, the deliberately broad “anything React can render” type: JSX elements, strings, numbers, arrays of those, fragments, portals, and, the part that matters in a minute, null, undefined, and boolean, which render as nothing. That breadth is why {condition && <Thing />} and {items.map(...)} both work when you drop them into JSX: a false renders nothing, and an array renders each element.
Third, there’s a narrower cousin you’ll meet in older code, ReactElement . Where ReactNode is anything renderable, ReactElement is a single JSX element and nothing else, so children: ReactElement rejects a string, two elements, or a fragment the moment someone passes one. 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 wraps children is fine for one undivided block of content. But real cards have structure: a header, a body, a footer, maybe an action in the top-right corner. How does the consumer fill each region? One prop per region is the obvious answer; a compound component is the better one. The card ships 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 import 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 part lives in a single card.tsx. Card is the container. Inside it, CardHeader groups the top region and 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; CardFooter is the bottom strip. The consumer picks the parts they need, orders them, and skips the rest.
To see why this beats a prop per region, build 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. When 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 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 existing call sites never change. It reads like the markup it produces.
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. Once a component is growing a prop for every region it might hold, stop adding props and start handing out pieces.
Each member of the family is ordinary: an L1-style typed component that wraps one element, owns its classes, and forwards className and ...rest. There’s nothing new to learn per 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. 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>. There’s no children in the destructure; children rides along inside ...rest and lands on the <div> automatically, so you only name it when 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.
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 pattern most clearly: a brand-new region, the top-right slot, is a new export, never a new prop on <Card>.
The pattern lives entirely in the packaging: a coordinated set of thin wrappers, exported together, each owning one region’s styling. Every part is a function you already know how to write.
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>keeps working untouched. The prop-per-region card would force you to edit the component and 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. No prop to add, no need to fork the component.
One caveat: 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 won’t error if you misuse them. 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>, no: these parts only carry classes, so reaching for context would be over-engineering. Context-linked compound components are a real pattern, just a later one.
A family of thin, composable parts is what shadcn, Radix, and Ariakit are built on. shadcn relies on it so heavily that it 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. You’re learning it by writing the thing you’ll soon import unmodified.
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 spread does the real work, because children is in rest: 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 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 named region: prop-as-slot, not a compound component
Section titled “One named region: prop-as-slot, not a compound component”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, 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:
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.)
Both forms, children and leftIcon, are typed ReactNode and forward className through cn(). They are the same tool; only the number of regions differs: children is the unnamed catch-all, leftIcon a named single slot. So which do you reach for? The rule is the central idea of the lesson:
Zero or one named region → prop-as-slot (or just
children). Two or more → compound.
One corollary overrides the count: if the consumer needs to reorder, omit, or independently restyle the regions, lean toward compound even at one region, because those three freedoms are exactly what compound components give and props don’t.
Walk the decision tree below for a few real components. Answer each question, and the walker lands you on a recommendation.
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.
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, a list appears only when it has items. React has no special syntax for this. It falls out of the JSX rules you already met, where booleans, null, and undefined render as nothing, so the two everyday patterns are just JavaScript expressions inside a {}.
{isOpen && <Panel />}
{isError ? <Alert /> : <Content />}The first is the on/off form. When isOpen is true, && evaluates to the right-hand side and <Panel /> renders; when it’s false, the whole expression is false, which renders as nothing. The second is the pick-one form: the ternary 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, the most common conditional-render mistake shipped to production. 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 next to an empty list, where the user can see it. The reason: 0 is falsy, so && short-circuits and the whole expression evaluates to 0 — not false, the number 0. A number is a renderable member of ReactNode, so React renders it. (The empty string '' does the same, for the same reason.) The && idiom is safe with a boolean on its left, because false renders nothing; 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. Three forms work, so pick whichever reads best at the call site:
{count > 0 && <List items={items} />}
{Boolean(count) && <List items={items} />}
{value != null && <Field value={value} />}Comparing to a number reads cleanest when you mean “more than zero.” Boolean(count) coerces explicitly when any non-zero count should show. And value != null is the one for a nullable value: it catches both null and undefined while letting through legitimate falsy values like 0 or ''. The rule under all three is the same: the left side of && in JSX must be a real boolean.
Fragments group without a wrapper
Section titled “Fragments group without a wrapper”A component must return one parent element, not two siblings side by side. Wrapping them in a <div> is the usual fix, but that extra node isn’t always free: it can break a flex or grid layout that expects direct children, or 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 .
A definition list is the clearest example. A <dl> expects bare <dt>/<dd> pairs as its children, so a <div> between them makes the HTML invalid. A component that renders one row must therefore 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 itself. The <dt> and <dd> come out as direct, valid children of whatever <dl> the consumer drops PlanRow into. This is the form you’ll reach for daily.
The shorthand can’t carry a key, and one case needs one: a fragment that is a list item. When you map over a list and each iteration emits two siblings, React needs a key on each to track which item is which, so you switch to the longhand <Fragment>:
{plans.map((plan) => ( <Fragment key={plan.id}> <dt>{plan.label}</dt> <dd>{plan.price}</dd> </Fragment>))}Why lists need a key comes in the next chapter. For now: inside a mapped list, use <Fragment key={...}>; outside a list, a fragment takes no key.
Children as a function: the render prop
Section titled “Children as a function: the render prop”There’s one more shape children can take, and you’ll read it far more often than you’ll write it. Instead of being content the component places, children is a function. The component owns some value, such as state or the result of a fetch, and rather than rendering 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 the function (invoices) => <InvoiceList items={invoices} />. DataLoader does the loading, then calls that function with the data, handing the render back to the consumer. Typed, children is (data: T) => ReactNode. The name for this is a render prop .
The pattern isn’t deprecated, but in 2026 it’s something to recognize, not a daily tool, because nearly every case that once reached for a render prop is now a custom hook: const invoices = useInvoices() does the same work with no nesting and no function-as-child. Recognize it when you read it, and reach for a hook before you write one.
Two related habits to skip: don’t iterate a component’s children with Children.map or Children.toArray, expose a data prop and map over the data instead; and don’t use cloneElement or Children.map to inject props into children, which is what the next lesson’s asChild does with a typed, sanctioned contract.
Composition is the first answer to prop drilling
Section titled “Composition is the first answer to prop drilling”Picture a layout nested a few levels deep: <Layout> renders <Page>, which renders <Header>, which renders <Toolbar>. Only the <Toolbar> at the bottom needs the current user, to show their avatar. But user enters at the top, so you thread it down: <Layout> takes a user prop and passes it to <Page>, which passes it to <Header>, which passes it to <Toolbar>. Three middle components accept and forward a prop they never use, and each now carries user in its signature for no reason of its own. That is prop drilling.
The fix needs no new tool, just the composition from this lesson. Instead of <Layout> creating the toolbar deep inside, let it accept the finished toolbar as a slot, through children or a named ReactNode prop. The consumer wires user into <Toolbar user={user} /> at the call site and hands the assembled element down. The middle layers pass an opaque ReactNode through, so they never see user or name it.
user is threaded through every layer, including the middle boxes that accept it only to pass it on.
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, so user jumps straight to the bottom without touching Page or Header.
Composition makes the component that needs the prop and the component that has it into siblings at the call site. They meet where user already is, so nothing has to drill.
When a value has to reach somewhere deep, try composition first. Prop drilling is not automatically a bug that demands Context; we cover where state should live, and Context for genuinely cross-cutting values, in a later lesson.