Component Composition Patterns in React That Actually Scale

Component Composition Patterns in React That Actually Scale
Brandon Perfetti

Technical PM + Software Engineer

Topics:React component compositionScalable UI architectureReact Best Practices
Tech:ReactTypeScriptJavaScript

React component APIs often fail the same way: they start simple, then grow into a prop matrix nobody fully understands.

You’ve probably seen it:

  • Modal gets size, variant, footerMode, showClose, danger, dense, disableBackdrop, stickyActions.
  • Product teams combine flags in ways the original author never anticipated.
  • Bugs appear in combinations, not individual props.

The fix is not “write better conditionals.” The fix is architectural: model behavior through composition boundaries instead of prop permutations.

This article gives a practical framework for choosing composition patterns that stay understandable as teams and requirements grow.

Why prop-heavy components break down

A big prop API has two hidden costs:

  1. Implicit contracts: “if A is true, B must be false” rules live in tribal knowledge.
  2. Change blast radius: every new variant risks breaking existing combinations.

In plain English: once a component has too many modes, you are maintaining a state machine without admitting it.

Pattern 1: Compound components for shared state + flexible structure

Use compound components when:

  • parent and children must share behavior,
  • consumers need layout freedom.
import { createContext, useContext, useState, type ReactNode } from "react";

type TabsCtx = {
  active: string;
  setActive: (id: string) => void;
};

const TabsContext = createContext<TabsCtx | null>(null);

function useTabs() {
  const ctx = useContext(TabsContext);
  if (!ctx) throw new Error("Tabs subcomponents must be used inside <Tabs>");
  return ctx;
}

export function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
  const [active, setActive] = useState(defaultTab);
  return <TabsContext.Provider value={{ active, setActive }}>{children}</TabsContext.Provider>;
}

export function TabList({ children }: { children: ReactNode }) {
  return <div role="tablist">{children}</div>;
}

export function Tab({ id, children }: { id: string; children: ReactNode }) {
  const { active, setActive } = useTabs();
  return (
    <button role="tab" aria-selected={active === id} onClick={() => setActive(id)}>
      {children}
    </button>
  );
}

export function TabPanel({ id, children }: { id: string; children: ReactNode }) {
  const { active } = useTabs();
  if (active !== id) return null;
  return <section role="tabpanel">{children}</section>;
}

Why it scales:

  • one place owns interaction rules,
  • consumers compose markup explicitly,
  • API remains small.

Pattern 2: Slot/polymorphic composition for structural flexibility

Use slot-style APIs when consumers need to choose the rendered element while preserving design-system behavior.

Good fit:

  • button-like components used as links, router links, or native buttons.

Bad fit:

  • every component being polymorphic “just in case.”

Rule: apply polymorphism only where structural variation is a real requirement.

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  asChild?: boolean;
  variant?: "primary" | "ghost";
  children: React.ReactNode;
};

function Button({ asChild = false, variant = "primary", children, ...rest }: ButtonProps) {
  const className = variant === "primary" ? "btn btn-primary" : "btn btn-ghost";

  if (asChild && React.isValidElement(children)) {
    return React.cloneElement(children as React.ReactElement, {
      className: `${className} ${(children as React.ReactElement).props.className ?? ""}`.trim(),
      ...rest,
    });
  }

  return (
    <button className={className} {...rest}>
      {children}
    </button>
  );
}

This pattern keeps visual behavior centralized while allowing route/link wrappers where needed.

Pattern 3: Headless hooks + presentational wrappers

This is the most practical long-term pattern for many teams.

Split into:

  • Headless hook: state transitions, effects, event rules.
  • UI wrapper: opinionated markup/styling for fast usage.

Benefits:

  • product teams move fast with wrappers,
  • advanced screens can drop to hooks,
  • logic stays reusable and testable.
type UseDisclosureReturn = {
  open: boolean;
  openPanel: () => void;
  closePanel: () => void;
  togglePanel: () => void;
};

function useDisclosure(initial = false): UseDisclosureReturn {
  const [open, setOpen] = useState(initial);
  return {
    open,
    openPanel: () => setOpen(true),
    closePanel: () => setOpen(false),
    togglePanel: () => setOpen((v) => !v),
  };
}

function DisclosureCard({ title, children }: { title: string; children: React.ReactNode }) {
  const { open, togglePanel } = useDisclosure(false);

  return (
    <section>
      <button onClick={togglePanel} aria-expanded={open}>{title}</button>
      {open ? <div>{children}</div> : null}
    </section>
  );
}

The hook owns behavior; the wrapper owns markup and styling.

Pattern 4: Render props for highly variable rendering surfaces

Render props are still useful when behavior is shared but UI output differs significantly by consumer.

If every consumer renders very different UI states, a render prop can be clearer than exposing a dozen booleans.

type LoadState<T> =
  | { status: "idle" | "loading" }
  | { status: "error"; error: string }
  | { status: "success"; data: T };

function DataBoundary<T>({
  state,
  children,
}: {
  state: LoadState<T>;
  children: (data: T) => React.ReactNode;
}) {
  if (state.status === "loading" || state.status === "idle") return <p>Loading...</p>;
  if (state.status === "error") return <p role="alert">{state.error}</p>;
  return <>{children(state.data)}</>;
}

This keeps state handling policy centralized while giving consumers complete rendering control.

Choosing the right pattern: decision matrix

Ask these five questions in order:

  1. Who owns state?
  2. Who owns structure?
  3. Who owns presentation?
  4. How many teams will consume this API?
  5. What is the cost of changing this contract later?

Use this mapping:

  • Shared state + flexible structure -> compound components
  • Shared behavior + different visual output -> headless hook or render prop
  • Need element polymorphism -> slot pattern
  • Single stable use case -> simple component, no heavy composition

TypeScript guardrails that prevent composition drift

Use TypeScript to enforce contracts before runtime:

  • prefer discriminated unions for mutually exclusive modes,
  • fail loudly when subcomponents are used out of context,
  • keep prop surfaces narrow and intentional.

Example:

type BannerProps =
  | { kind: "info"; onDismiss?: never }
  | { kind: "dismissible"; onDismiss: () => void };

This prevents invalid combinations at compile time.

Migration plan: prop soup -> composable API

You do not need a big-bang rewrite.

  1. Pick one high-churn component.
  2. Freeze new prop additions unless they pass review.
  3. Introduce compositional API alongside legacy API.
  4. Migrate highest-value call sites first.
  5. Add deprecation warnings for legacy modes.
  6. Remove legacy API only after adoption threshold.

Testing what actually matters

Do not stop at snapshots. Test contracts:

  • interaction behavior (state wiring),
  • accessibility semantics,
  • common consumer compositions.

If consumers depend on a pattern, test that pattern directly.

Common anti-patterns

  • “God components” with too many mode flags.
  • Polymorphism everywhere with no clear reason.
  • Hook extraction without a coherent behavior boundary.
  • Composition APIs that require reading internal code to use correctly.

Closing

Scalable React architecture is not about maximum abstraction. It is about explicit ownership boundaries.

When behavior is expressed through composition instead of flag combinations, teams move faster with fewer regressions and clearer APIs.

If you apply one change this week, apply this: reject new mode props by default, and ask whether the use case is better modeled as composition.