4 min read
Props, Children, and Component Composition in React
When I started learning React, I thought of components mostly as containers for JSX. You pass some data in, you get some HTML out. Props were just a way to configure what the component renders.
That mental model works fine at first, but it breaks down quickly when you start building real UIs. You end up with components that are too specific, hard to reuse, and deeply nested inside each other. The thing that fixed this for me was understanding composition — specifically, how the children prop changes the way you think about building components.
What Props Actually Are
Props are how a parent component talks to a child. They flow one way: down. A parent passes data to a child, and the child uses it. The child can't write back to the parent through props — if it needs to, you pass a callback function as a prop.
type ButtonProps = {
label: string;
onClick: () => void;
};
function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}
// Usage
<Button label="Save" onClick={() => console.log("saved")} />
This is straightforward. The component is a function, props are its arguments, and TypeScript types make the contract explicit.
One thing that trips people up early: you can pass anything as a prop. Strings and numbers are obvious. But you can also pass functions, objects, arrays, and even other React elements. That last one is where things get interesting.
The children Prop
Every React component automatically accepts a children prop. It represents whatever you put between the opening and closing tags of the component.
function Card({ children }: { children: React.ReactNode }) {
return (
<div className="border rounded p-4">
{children}
</div>
);
}
// Usage
<Card>
<h2>Hello</h2>
<p>Some content here</p>
</Card>
The children type is React.ReactNode, which includes things React can render, such as strings, numbers, JSX elements, arrays of elements, fragments, portals, booleans, null, and undefined.
What's useful about this is that Card doesn't need to know anything about what it's rendering. It just provides the wrapper. The caller decides what goes inside.
Compare that to a version without children:
function Card({ title, body }: { title: string; body: string }) {
return (
<div className="border rounded p-4">
<h2>{title}</h2>
<p>{body}</p>
</div>
);
}
This works for simple cases, but what if you want to put a list inside the card? Or a form? Or another component? You'd have to keep adding props. The component gets rigid fast.
The children prop sidesteps this by letting the parent decide the content structure entirely.
Composition vs Configuration
If you want to go deeper on avoiding prop drilling through composition, there's a dedicated article on composition over prop drilling that covers slots and named element props in more detail.
There are two ways to make components flexible: configuration (more props) and composition (children + structural slots).
Configuration works well when the variation is in data:
<Avatar size="large" src="/me.jpg" alt="Profile photo" />
Composition works better when the variation is in structure:
<Dialog>
<DialogHeader>
<h2>Confirm delete</h2>
</DialogHeader>
<DialogBody>
<p>This action cannot be undone.</p>
</DialogBody>
<DialogFooter>
<Button onClick={onCancel}>Cancel</Button>
<Button onClick={onConfirm}>Delete</Button>
</DialogFooter>
</Dialog>
Here, Dialog, DialogHeader, DialogBody, and DialogFooter are all small components that only care about layout. They don't care what's inside them. The caller controls the content completely.
This is a lot more reusable than a Dialog component that accepts title, body, cancelLabel, and confirmLabel as props. The moment you need a dialog with two paragraphs, or a form in the body, or three action buttons, the prop-based version becomes a problem.
Passing Elements as Props
You're not limited to children. You can pass React elements through any prop. This is useful for things like icons or custom renderers.
type InputProps = {
label: string;
icon?: React.ReactNode;
};
function Input({ label, icon }: InputProps) {
return (
<div className="flex items-center gap-2">
{icon && <span className="text-gray-400">{icon}</span>}
<input placeholder={label} />
</div>
);
}
// Usage
<Input label="Search" icon={<SearchIcon />} />
This keeps the Input component decoupled from any specific icon library. The parent provides whatever element it wants, and Input just renders it in the right place.
A Practical Example: Layout Components
Layout components are where composition shines most clearly. Here's a simple page layout:
type PageLayoutProps = {
sidebar: React.ReactNode;
children: React.ReactNode;
};
function PageLayout({ sidebar, children }: PageLayoutProps) {
return (
<div className="flex gap-8">
<aside className="w-64 shrink-0">{sidebar}</aside>
<main className="flex-1">{children}</main>
</div>
);
}
// Usage
<PageLayout
sidebar={<Navigation links={navLinks} />}
>
<ArticleList articles={articles} />
</PageLayout>
The PageLayout component handles the grid. The caller decides what goes in the sidebar and what goes in the main area. You could use this layout for a documentation site, a dashboard, or a blog without changing PageLayout at all.
What Changed in How I Think About This
The shift for me was moving from "what data does this component need?" to "what structure does this component provide?"
Configuration-heavy components (lots of props) tend to leak knowledge about their internals. Every time the design changes, you add another prop. Eventually the component has 15 optional props and it's hard to understand what it actually does.
Composition-heavy components stay stable. You define the slots and the layout once. The caller fills them in. The component's API barely changes even as the content inside it varies wildly.
The rule I use now: if the variation is in data, use props. If the variation is in structure, use children or element props. When you're unsure, favour composition — it's easier to add a prop to a flexible component than to break apart a rigid one.