Paulund
Subscribe

Props, children and component composition in React

24 October 2026, 4 min read

On this page
  1. Props flow one way
  2. The children prop
  3. Configuration or composition
  4. Elements as props and named slots
  5. Composition fixes prop drilling
  6. When Context is the better tool
  7. Related patterns

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.

TSX
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.

TSX
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:

TSX
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:

TSX
<Avatar size="large" src="/me.jpg" alt="Profile photo" />

Composition suits variation in structure:

TSX
<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:

TSX
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:

TSX
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:

TSX
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:

TSX
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.

These patterns build on the same idea:

Next in React

24 October 2026Sharing state in React without Redux

Tags

  • React
  • TypeScript
  • Components
  • Composition
  • Patterns