Alejo Pequeño

Design Engineer

Compose, Don't Configure

There's a moment in every reusable component's life when something breaks. It started as a simple <Button> with primary and secondary variants. Six months later it has 23 boolean props, three renderX props, and nobody wants to open it because they know there's a five-level nested if inside.

People call it the prop-ocalypse, and it's the natural consequence of skipping composition pattern.

In this post:

  • What composition pattern actually means (beyond the cliché)
  • Why "configurable" components turn into hell
  • How to apply it step by step with a real example (a Card)
  • How to handle shared state between pieces
  • How to wire it up with data fetching (the part where it really shines)
  • When NOT to use it (yes, there are moments)

Let's go.


The problem: the component that knows too much

Let's start with a classic. You need to build a Card component for your design system. The first version is innocent:

type CardProps = {
  title: string;
  description: string;
  imageUrl?: string;
};
 
export const Card = ({ title, description, imageUrl }: CardProps) => (
  <div className="card">
    {imageUrl && <img src={imageUrl} alt={title} />}
    <h3>{title}</h3>
    <p>{description}</p>
  </div>
);

Done. Until requirements pile up: action buttons, badges, two CTAs, prices, ratings, author info, timestamps. Six months in, the type looks like this:

type CardProps = {
  title: string;
  description: string;
  imageUrl?: string;
  imagePosition?: "top" | "left" | "right";
  actionLabel?: string;
  onAction?: () => void;
  secondaryActionLabel?: string;
  onSecondaryAction?: () => void;
  badge?: { text: string; variant: "success" | "warning" | "error" };
  price?: number;
  rating?: number;
  author?: { name: string; avatar: string };
  timestamp?: Date;
  variant?: "default" | "product" | "feed" | "dashboard";
  showFooter?: boolean;
  // ... 15 more props
};

And inside, the component becomes a graveyard of ifs:

{
  variant === "product" && price && <PriceTag value={price} />;
}
{
  variant === "feed" && author && <AuthorBlock {...author} />;
}
{
  badge && <Badge variant={badge.variant}>{badge.text}</Badge>;
}
{
  showFooter && (actionLabel || secondaryActionLabel) && (
    <div className="card-footer">{/* ... */}</div>
  );
}

This is a god component. And the problem isn't that it's "badly written." The problem is that the design pattern was wrong from the start.


What is composition pattern, for real?

Composition pattern is building complex components by combining smaller, independent pieces, instead of having one monolithic component you configure with props.

The idea is ripped straight from HTML. Think about <select>:

<select>
  <optgroup label="Fruits">
    <option value="apple">Apple</option>
    <option value="banana">Banana</option>
  </optgroup>
</select>

Nobody ever wrote this:

<select
  options='[{"type":"group","label":"Fruits","options":[{"value":"apple","label":"Apple"}]}]'
/>

Because it would be horrible. HTML gives you pieces (select, optgroup, option) and you combine them however you need.

This is the core idea behind Fernando Rojo's talk "Composition Is All You Need" (Head of Mobile at Vercel, React Universe Conf 2025). If you haven't watched it yet, stop everything and go watch it. One of the best technical talks of the year.


Refactor: the Card as a compound component

Back to our Card. Instead of a component with 30 props, we're going to expose pieces the consumer composes:

import { ReactNode, createContext, useContext } from "react";
 
// 1. Root piece: just the visual container
const CardRoot = ({ children }: { children: ReactNode }) => (
  <div className="card">{children}</div>
);
 
// 2. Each part is independent and optional
const CardImage = ({ src, alt }: { src: string; alt: string }) => (
  <img className="card__image" src={src} alt={alt} />
);
 
const CardHeader = ({ children }: { children: ReactNode }) => (
  <header className="card__header">{children}</header>
);
 
const CardTitle = ({ children }: { children: ReactNode }) => (
  <h3 className="card__title">{children}</h3>
);
 
const CardDescription = ({ children }: { children: ReactNode }) => (
  <p className="card__description">{children}</p>
);
 
const CardBadge = ({
  variant = "default",
  children,
}: {
  variant?: "success" | "warning" | "error" | "default";
  children: ReactNode;
}) => <span className={`card__badge card__badge--${variant}`}>{children}</span>;
 
const CardFooter = ({ children }: { children: ReactNode }) => (
  <footer className="card__footer">{children}</footer>
);
 
// 3. Export everything under the same namespace
export const Card = Object.assign(CardRoot, {
  Image: CardImage,
  Header: CardHeader,
  Title: CardTitle,
  Description: CardDescription,
  Badge: CardBadge,
  Footer: CardFooter,
});

Now the consumer decides which pieces to use and in what order:

// Simple card
<Card>
  <Card.Title>Hello world</Card.Title>
  <Card.Description>A basic card.</Card.Description>
</Card>
 
// Product card
<Card>
  <Card.Image src="/sneaker.jpg" alt="Running shoe" />
  <Card.Badge variant="success">In stock</Card.Badge>
  <Card.Header>
    <Card.Title>Pro Running Shoe</Card.Title>
    <span className="card__price">$120</span>
  </Card.Header>
  <Card.Description>Built for long distances.</Card.Description>
  <Card.Footer>
    <Button>Add to cart</Button>
  </Card.Footer>
</Card>
 
// Social feed card with author
<Card>
  <Card.Header>
    <UserAvatar user={post.author} />
    <Card.Title>{post.author.name}</Card.Title>
    <Timestamp date={post.createdAt} />
  </Card.Header>
  <Card.Description>{post.content}</Card.Description>
  <Card.Footer>
    <LikeButton />
    <CommentButton />
  </Card.Footer>
</Card>

Look at what we gained:

  • Zero internal ifs in Card
  • Each piece has a single responsibility
  • The consumer decides the structure without fighting props
  • Adding a new variant doesn't require touching Card
  • The call site reads like HTML, not like configuration

The real deal: composition pattern with data fetching

Up to now the example is nice but kind of academic. The case where composition pattern actually saves your life is when you throw data fetching into the mix.

Imagine you need to show a Post in different parts of the app: in the feed, on the detail page, in a search view. The layout changes everywhere, but the post data is the same.

The naive approach

We'll use TanStack Query for the fetching layer:

// hooks/usePostQuery.ts
export const usePostQuery = (postId: string) => {
  return useQuery({
    queryKey: ["post", postId],
    queryFn: () => fetchPost(postId),
  });
};
 
// PostCard.tsx
export const PostCard = ({ postId }: { postId: string }) => {
  const { data: post, isLoading, error } = usePostQuery(postId);
 
  if (isLoading) return <Skeleton />;
  if (error) return <ErrorState />;
  if (!post) return null;
 
  return (
    <article className="post-card">
      <header>
        <img src={post.author.avatar} alt={post.author.name} />
        <h3>{post.author.name}</h3>
      </header>
      <h2>{post.title}</h2>
      <p>{post.content}</p>
      <footer>
        <button>👍 {post.likes}</button>
        <button>💬 {post.comments}</button>
      </footer>
    </article>
  );
};

Works. But when the next request lands ("in the feed I only want title and author, no body") you start stacking props:

<PostCard postId="123" hideContent hideFooter compactHeader />

And we're back to the prop-ocalypse, but now with queries inside.

Composition pattern + fetching

The idea: a provider that fetches once, and pieces that consume that data without knowing how it was obtained.

// post-card/post-card-context.tsx
import { createContext, useContext, ReactNode } from "react";
import { usePostQuery } from "@/hooks/usePostQuery";
 
type Post = {
  id: string;
  title: string;
  content: string;
  likes: number;
  comments: number;
  author: { name: string; avatar: string };
  createdAt: string;
};
 
const PostContext = createContext<Post | null>(null);
 
export const usePost = (): Post => {
  const post = useContext(PostContext);
  if (!post) {
    throw new Error("PostCard.* must be used inside <PostCard>");
  }
  return post;
};
 
// post-card/post-card.tsx
type PostCardProps = {
  postId: string;
  children: ReactNode;
  fallback?: ReactNode;
  errorFallback?: ReactNode;
};
 
const PostCardRoot = ({
  postId,
  children,
  fallback = <PostCardSkeleton />,
  errorFallback = <PostCardError />,
}: PostCardProps) => {
  const { data: post, isLoading, error } = usePostQuery(postId);
 
  if (isLoading) return <>{fallback}</>;
  if (error || !post) return <>{errorFallback}</>;
 
  return (
    <PostContext.Provider value={post}>
      <article className="post-card">{children}</article>
    </PostContext.Provider>
  );
};

Now the pieces are small, focused, and know nothing about fetching:

// post-card/pieces.tsx
const PostAuthor = () => {
  const post = usePost();
  return (
    <div className="post-card__author">
      <img src={post.author.avatar} alt={post.author.name} />
      <span>{post.author.name}</span>
    </div>
  );
};
 
const PostTitle = () => {
  const post = usePost();
  return <h2 className="post-card__title">{post.title}</h2>;
};
 
const PostContent = () => {
  const post = usePost();
  return <p className="post-card__content">{post.content}</p>;
};
 
const PostStats = () => {
  const post = usePost();
  return (
    <div className="post-card__stats">
      <button>👍 {post.likes}</button>
      <button>💬 {post.comments}</button>
    </div>
  );
};
 
const PostTimestamp = () => {
  const post = usePost();
  return (
    <time dateTime={post.createdAt}>
      {new Date(post.createdAt).toLocaleDateString()}
    </time>
  );
};
 
export const PostCard = Object.assign(PostCardRoot, {
  Author: PostAuthor,
  Title: PostTitle,
  Content: PostContent,
  Stats: PostStats,
  Timestamp: PostTimestamp,
});

And here comes the magic

The same PostCard gets used in a thousand places, each composing what it needs:

// Feed: full card
<PostCard postId={post.id}>
  <PostCard.Author />
  <PostCard.Timestamp />
  <PostCard.Title />
  <PostCard.Content />
  <PostCard.Stats />
</PostCard>
 
// Related posts sidebar: just title and author
<PostCard postId={post.id}>
  <PostCard.Title />
  <PostCard.Author />
</PostCard>
 
// Search result: title and preview, no stats
<PostCard postId={post.id}>
  <PostCard.Title />
  <PostCard.Content />
  <PostCard.Timestamp />
</PostCard>
 
// Hover preview: title only
<PostCard postId={post.id}>
  <PostCard.Title />
</PostCard>

Look at what we gained:

  • One query per card, no matter how many pieces you use (TanStack Query also caches across cards if postId repeats).
  • Loading and error are handled in one place. Pieces never see undefined.
  • Zero optional chaining in pieces: post.title always exists.
  • Adding a new layout doesn't require touching the component, you just compose differently.
  • Each piece is testable in isolation by mocking the PostContext.Provider with a fake value.

And the coolest part: if tomorrow the API changes and usePostQuery now uses GraphQL, tRPC, or whatever, the pieces don't care. That's the decoupling you get from composition pattern done right.


Anti-patterns vs Composition: the dark side

❌ Anti-pattern 1: Stacking boolean props

<Modal
  showHeader
  showCloseButton
  showFooter
  hasBackdrop
  isClosable
  isFullscreen
  shouldFocusOnOpen
/>

Every new bool is a new code branch in the component. After 5 bools you have 32 possible combinations, and only 4 are actually tested.

❌ Anti-pattern 2: Render props for everything

<Card
  renderHeader={() => <CustomHeader />}
  renderBody={() => <CustomBody />}
  renderFooter={() => <CustomFooter />}
/>

Not terrible, but you're stuck halfway. If you're going to let the consumer render everything, give them pieces instead of slots.

❌ Anti-pattern 3: POJO-driven configuration

<DataTable
  columns={[
    { id: "name", label: "Name", sortable: true, render: (v) => <b>{v}</b> },
    { id: "age", label: "Age", align: "right" },
  ]}
/>

You're building a mini-DSL inside an object. Hard to type properly, hard to extend, and worse for the LLMs that'll help you write the code (yes, that matters now).

Better:

<DataTable data={users}>
  <DataTable.Column id="name" sortable>
    {(user) => <b>{user.name}</b>}
  </DataTable.Column>
  <DataTable.Column id="age" align="right">
    {(user) => user.age}
  </DataTable.Column>
</DataTable>

When NOT to use composition pattern

It's not a silver bullet. There are cases where a component with props is better:

  • Hyper-specific, single-use components. A LoginForm only used at /login. Don't invent pieces for it.
  • When you need to enforce structure. If your design system says "every error has icon + title + description, no exceptions", an <ErrorMessage> with props is safer than letting people compose freely.
  • Very simple components. A <Spinner size="md" /> doesn't need to be composed.

Mental rule: composition shines when you expect variability. If you know the component won't change much, plain props is simpler.


Rules for composing well

After many refactors, these are the principles I apply:

  1. One piece, one responsibility. Card.Title only renders a title. Don't shove tracking logic in there.
  2. Bring in Context only when you need it. If pieces don't share state, skip the Provider. YAGNI.
  3. Explicit throws in the Context hook. If someone uses Card.Toggle outside <Card>, fail loudly in dev, not silently in runtime.
  4. Don't mix patterns. Don't have a Card that accepts both <Card.Title> and a title prop. Pick one.

Wrap-up

Composition pattern isn't just another trick for your CV. It's a different way of thinking about component APIs: instead of imagining every possible configuration and exposing them as props, you give the consumer the pieces and trust them to assemble their use case.

That requires a mental shift: you go from "components that control everything" to "components that offer capabilities." And once that click happens, everything else falls into place: your design system scales, your team has fewer arguments about new props, and components age well.

"Composition is all you need." — Fernando Rojo

That's the whole secret: you don't optimize for today, you optimize for the next six months.