Composition over Configuration

Using children and slots instead of growing a component's props forever.

What you'll learn

  • How a component ends up with a dozen boolean props
  • Composing with children instead
  • Passing elements as props for named slots
  • Using composition to avoid prop drilling

How components go wrong

A component starts simple, then every new requirement adds a prop. Before long the signature is a list of switches, and the body is a thicket of conditionals:

<Dialog
  title="Delete file"
  showIcon
  iconType="warning"
  showFooter
  primaryLabel="Delete"
  secondaryLabel="Cancel"
  hideSecondary={false}
  danger
/>

Every caller must learn the switches; every new case adds another. Worse, the props are not independent — hideSecondary only matters if showFooter is true — so most combinations are meaningless, and nothing stops you writing one.

The alternative is to let the caller pass content instead of instructions.

children

The plainest form: whatever goes between the tags becomes children. The component controls layout; the caller controls content.

function Alert({ children }) {
  return (
    <div style={{ border: "1px solid #f5c2c7", background: "#fff5f5", borderRadius: 6, padding: 12 }}>
      {children}
    </div>
  );
}

export default function App() {
  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
      <Alert>
        <strong>Careful.</strong> This cannot be undone.
      </Alert>

      <Alert>
        <p style={{ margin: 0 }}>Anything can go here — including a button.</p>
        <button style={{ marginTop: 8 }}>Undo</button>
      </Alert>
    </div>
  );
}

Alert has one prop and no conditionals, yet renders two quite different things. It cannot be configured wrongly, because there is nothing to configure.

Slots: elements as props

children gives you one hole. When a component has several distinct regions, give each its own prop — a prop can hold an element just as easily as a string:

function Card({ header, children, footer }) {
  return (
    <section style={{ border: "1px solid #ddd", borderRadius: 8, overflow: "hidden" }}>
      <div style={{ background: "#fafafa", padding: "8px 12px", borderBottom: "1px solid #eee" }}>
        {header}
      </div>

      <div style={{ padding: 12 }}>{children}</div>

      {footer && (
        <div style={{ padding: "8px 12px", borderTop: "1px solid #eee", textAlign: "right" }}>
          {footer}
        </div>
      )}
    </section>
  );
}

export default function App() {
  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
      <Card
        header={<strong>Delete file</strong>}
        footer={<><button>Cancel</button> <button>Delete</button></>}
      >
        <p style={{ margin: 0 }}>This cannot be undone.</p>
      </Card>
    </div>
  );
}

The footer is optional without a showFooter prop: pass one or do not. The absence of the element is the switch, and the impossible states disappear.

Specialisation

When a configuration recurs, do not add a flag — wrap the general component in a specific one. The specific component becomes the readable name for that case:

function Button({ children, ...props }) {
  return (
    <button {...props} style={{ padding: "4px 10px", borderRadius: 4, ...props.style }}>
      {children}
    </button>
  );
}

// A specialisation, not a `danger` prop on Button.
function DangerButton({ children, ...props }) {
  return (
    <Button {...props} style={{ background: "#dc2626", color: "white", border: "none" }}>
      {children}
    </Button>
  );
}

export default function App() {
  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, display: "flex", gap: 8 }}>
      <Button onClick={() => {}}>Cancel</Button>
      <DangerButton onClick={() => {}}>Delete</DangerButton>
    </div>
  );
}

Note ...props — spreading the rest of the props through means the wrapper does not have to re-declare onClick, disabled, type and the rest. That is the rest and spread from the refresher earning its keep.

Composition beats prop drilling

Passing a prop through layers that do not use it is often avoidable without context. If a parent already has the data, it can build the element and pass it down as content — the middle layer never sees it:

function Layout({ sidebar, children }) {
  // Layout knows nothing about users. It just places what it is given.
  return (
    <div style={{ display: "flex", gap: 12 }}>
      <aside style={{ width: 120, background: "#f4f4f5", padding: 8, borderRadius: 6 }}>
        {sidebar}
      </aside>
      <main style={{ flex: 1 }}>{children}</main>
    </div>
  );
}

function UserBadge({ user }) {
  return <strong>{user.name}</strong>;
}

export default function App() {
  const user = { name: "Ada" };

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
      <Layout sidebar={<UserBadge user={user} />}>
        <p style={{ margin: 0 }}>Main content.</p>
      </Layout>
    </div>
  );
}

Layout takes no user prop, so it never has to change when the badge does. Reach for context when composition cannot reach — not as the first answer to drilling.

When configuration is right

Composition is not always the answer. A prop is better when the value really is data rather than content: <Input type="email" />, <Avatar size={32} />, <List items={rows} />. The smell is not props in general — it is booleans that turn parts of the render on and off, especially several that interact.

Common mistakes

  • A boolean per variation. Three flags make eight states you must think about; most are nonsense.
  • Rebuilding children. If a wrapper only ever renders one thing, it did not need to be configurable.
  • Context for everything. Try passing content down first.

Exercise

Notice has grown three booleans that interact. Rewrite it to take children and an optional action slot, then update both call sites — the flags should disappear entirely.

function Notice({ text, showTitle, title, showAction, actionLabel }) {
  return (
    <div style={{ border: "1px solid #ddd", borderRadius: 6, padding: 12, marginBottom: 8 }}>
      {showTitle && <strong>{title}</strong>}
      <p style={{ margin: "4px 0" }}>{text}</p>
      {showAction && <button>{actionLabel}</button>}
    </div>
  );
}

export default function App() {
  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
      <Notice showTitle title="Heads up" text="Your trial ends soon." showAction actionLabel="Renew" />
      <Notice text="Saved." showTitle={false} showAction={false} />
    </div>
  );
}