Props, children and component composition in React
On this page
Props are how a parent passes data to a child. The children prop and other element props let the parent pass finished UI instead. That second idea is composition, and it fixes most cases of prop drilling without reaching for Context.
Props flow one way
Props flow down. A parent passes data to a child and the child uses it. The child can't write back through props. If it needs to tell the parent something, the parent passes a callback, which the child usually wires to a DOM event handler.
type ButtonProps = {
label: string;
onClick: () => void;
};
function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}
<Button label="Save" onClick={() => console.log("saved")} />The component is a function, props are its arguments, and the TypeScript type makes the contract explicit.
You can pass anything as a prop: strings, numbers, functions, objects, arrays, and other React elements. Passing elements is where composition starts.
The children prop
children is whatever you put between a component's opening and closing tags.
function Card({ children }: { children: React.ReactNode }) {
return <div className="border rounded p-4">{children}</div>;
}
<Card>
<h2>Hello</h2>
<p>Some content here</p>
</Card>Its type is React.ReactNode. That covers anything React can render: strings, numbers, JSX elements, arrays of elements, fragments, portals, booleans, null and undefined.
Card doesn't need to know what it renders. It provides the wrapper and the caller decides what goes inside. Compare a version built from props:
function Card({ title, body }: { title: string; body: string }) {
return (
<div className="border rounded p-4">
<h2>{title}</h2>
<p>{body}</p>
</div>
);
}That works until you want a list, a form or another component inside the card. Each new case means another prop, and the component gets rigid fast.
Configuration or composition
There are two ways to make a component flexible. Configuration adds props. Composition adds children and named slots.
Configuration suits variation in data:
<Avatar size="large" src="/me.jpg" alt="Profile photo" />Composition suits variation in structure:
<Dialog>
<DialogHeader>
<h2>Confirm delete</h2>
</DialogHeader>
<DialogBody>
<p>This action cannot be undone.</p>
</DialogBody>
<DialogFooter>
<Button label="Cancel" onClick={onCancel} />
<Button label="Delete" onClick={onConfirm} />
</DialogFooter>
</Dialog>Dialog, DialogHeader, DialogBody and DialogFooter only handle layout. A Dialog that took title, body, cancelLabel and confirmLabel props would break the moment you needed two paragraphs, a form in the body, or three buttons.
Configuration-heavy components tend to grow a prop for every design change until nobody can tell what they do. Sensible defaults help, but 15 optional props is still a sign the component should take children. A composed component keeps the same small API while the content inside it changes.
The rule I use: if the variation is in data, use props. If it is in structure, use children or element props. When unsure, favour composition, because adding a prop to a flexible component is easier than breaking apart a rigid one.
Elements as props and named slots
children gives you one slot. Any other prop can hold an element too, which suits icons and 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>
);
}
<Input label="Search" icon={<SearchIcon />} />Input stays independent of any icon library. The parent picks the element and Input puts it in the right place.
Layout components are where named slots pay off most. Each slot is a typed ReactNode prop:
type PageShellProps = {
header: React.ReactNode;
sidebar: React.ReactNode;
children: React.ReactNode;
};
function PageShell({ header, sidebar, children }: PageShellProps) {
return (
<div className="grid">
<header>{header}</header>
<aside className="w-64 shrink-0">{sidebar}</aside>
<main className="flex-1">{children}</main>
</div>
);
}
<PageShell header={<Header user={user} />} sidebar={<Nav items={links} />}>
<ProductList products={products} />
</PageShell>PageShell handles the grid. It doesn't care whether the header is a search bar or a user menu, so the same shell works for a docs site, a dashboard or a blog. You don't end up with a ProductPageShell and a CartPageShell that are 90% the same code.
Composition fixes prop drilling
Prop drilling is when a prop travels through components that don't use it, just to reach the one that does. Here a page fetches a user and the header several layers down needs it:
function Page() {
const user = useUser();
return <Layout user={user} />;
}
function Layout({ user }: { user: User }) {
return <Shell user={user} />;
}
function Shell({ user }: { user: User }) {
return <Header user={user} />;
}
function Header({ user }: { user: User }) {
return <p>Hello, {user.name}</p>;
}Layout and Shell only pass user along. Instead of passing the data down, pass the finished Header down as a child:
function Page() {
const user = useUser();
return (
<Layout>
<Shell>
<Header user={user} />
</Shell>
</Layout>
);
}
function Layout({ children }: { children: React.ReactNode }) {
return <div className="layout">{children}</div>;
}
function Shell({ children }: { children: React.ReactNode }) {
return <div className="shell">{children}</div>;
}Layout and Shell are now generic containers. Page owns the data, so Page does the wiring.
They are also easier to test. Both take any ReactNode, so a test can mount them with a stub child and no provider tree.
The second rule I use: props that describe what a component does are fine. Props that describe what its children do probably shouldn't exist. If you are passing a prop you won't use at this level, pass a child instead.
When Context is the better tool
Composition changes when you reach for Context. It doesn't replace it. Use Context when the data is global and many leaves need it, such as a theme, a locale, or the current user for authorisation checks. If you are adding a Context only because you're tired of typing user={user}, you probably want composition.
If two siblings need the same state, lift it to their shared parent first. Sharing state in React without Redux covers lifting state, Context with TypeScript, and when a store makes sense.
Related patterns
These patterns build on the same idea:
- Compound components share state between a parent and several co-operating children.
- Render props share behaviour by passing a function that returns what to render.
- Higher-order components wrap a component to add behaviour, and hooks have mostly replaced them.