React Component Composition Patterns: A Practical Guide

Learn modern React composition patterns—children, render props, compound components, headless hooks, and more—with examples and design tips.

ASOasis
8 min read
React Component Composition Patterns: A Practical Guide

Image used for representation purposes only.

Why composition matters in React

“Composition over inheritance” is more than a slogan in React—it’s the foundation of how we build UIs that scale. Composition lets you:

  • Reuse behavior without deep class hierarchies
  • Keep components small, testable, and focused
  • Swap parts of a UI without rewriting the whole tree
  • Express constraints through APIs rather than through tightly coupled implementations

This guide walks through practical composition patterns you can apply today, with code examples and trade‑offs to consider.

Principles of a composable API

Before patterns, align on principles that make composition feel natural:

  • Small, single‑purpose pieces: Each component or hook should do one thing well.
  • Explicit data flow: Favor props and context over hidden globals or side effects.
  • Inversion of control: Let consumers decide “what” and “when”; your component provides “how.”
  • Stable contracts: Keep prop names and semantics consistent; avoid surprising defaults.
  • Accessibility first: Composition should not leak responsibilities like keyboard handling or ARIA.
  • Escape hatches: Provide lower‑level hooks or render props when a prebuilt component isn’t flexible enough.

Core patterns for composition

1) Containment with children

The simplest and most expressive API is often just children.

function Card({ children }: { children: React.ReactNode }) {
  return <div className='rounded border p-4 shadow-sm'>{children}</div>;
}

export function Example() {
  return (
    <Card>
      <h2>Title</h2>
      <p>Body content lives here.</p>
    </Card>
  );
}

Pros: dead simple, maximal flexibility. Cons: no structure—consumers must know where to put what.

2) Named slots via props

Add structure without sacrificing flexibility by exposing “slots” as props (strings, nodes, or components).

type PanelProps = {
  header?: React.ReactNode;
  footer?: React.ReactNode;
  children?: React.ReactNode;
};

function Panel({ header, children, footer }: PanelProps) {
  return (
    <section className='panel'>
      {header && <header className='panel__header'>{header}</header>}
      <div className='panel__body'>{children}</div>
      {footer && <footer className='panel__footer'>{footer}</footer>}
    </section>
  );
}

Named slots guide usage while keeping layout decoupled from content.

3) Render props (function‑as‑child)

Use when consumers need data or control from inside your component but want to render their own markup.

function MouseTracker({ children }: { children: (s: { x: number; y: number }) => React.ReactNode }) {
  const [pos, setPos] = React.useState({ x: 0, y: 0 });
  return (
    <div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
      {children(pos)}
    </div>
  );
}

export function Demo() {
  return (
    <MouseTracker>
      {({ x, y }) => <span>({x}, {y})</span>}
    </MouseTracker>
  );
}

Render props trade readability (extra functions) for maximum flexibility.

Compound components

Compound components feel like a single unit to consumers but are implemented as separate pieces that coordinate via context. Use this for Tabs, Accordions, Menus, Steppers, etc.

const TabsContext = React.createContext<{
  active: string;
  setActive: (id: string) => void;
} | null>(null);

function useTabs() {
  const ctx = React.useContext(TabsContext);
  if (!ctx) throw new Error('Tabs.* must be used within <Tabs>');
  return ctx;
}

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

Tabs.List = function List({ children }: { children: React.ReactNode }) {
  return <div role='tablist'>{children}</div>;
};

Tabs.Tab = function Tab({ id, children }: { id: string; children: React.ReactNode }) {
  const { active, setActive } = useTabs();
  const selected = active === id;
  return (
    <button
      role='tab'
      aria-selected={selected}
      aria-controls={`${id}-panel`}
      onClick={() => setActive(id)}
    >
      {children}
    </button>
  );
};

Tabs.Panel = function Panel({ id, children }: { id: string; children: React.ReactNode }) {
  const { active } = useTabs();
  return (
    <div role='tabpanel' id={`${id}-panel`} hidden={active !== id}>
      {children}
    </div>
  );
};

// Usage
export function TabsExample() {
  return (
    <Tabs defaultId='a'>
      <Tabs.List>
        <Tabs.Tab id='a'>Tab A</Tabs.Tab>
        <Tabs.Tab id='b'>Tab B</Tabs.Tab>
      </Tabs.List>
      <Tabs.Panel id='a'>Content for A</Tabs.Panel>
      <Tabs.Panel id='b'>Content for B</Tabs.Panel>
    </Tabs>
  );
}

Tips for compound components:

  • Keep context value stable with useMemo to reduce re‑renders.
  • Consider context selectors or component boundaries if many children re‑render on every change.
  • Own accessibility: roles, aria‑attrs, keyboard behavior.

Controlled, uncontrolled, and the state reducer pattern

A composable input lets consumers choose how to manage state.

  • Uncontrolled: internal state, expose defaultValue and onChange.
  • Controlled: external state via value and onChange.
  • Hybrid with state reducer: let consumers intercept and modify state transitions.
type ToggleAction = { type: 'toggle' } | { type: 'set'; on: boolean };

type ToggleProps = {
  on?: boolean; // controlled if defined
  defaultOn?: boolean;
  onChange?: (on: boolean, action: ToggleAction) => void;
  stateReducer?: (nextOn: boolean, action: ToggleAction) => boolean;
};

export function Toggle({ on, defaultOn = false, onChange, stateReducer }: ToggleProps) {
  const [internalOn, setInternalOn] = React.useState(defaultOn);
  const isControlled = on !== undefined;
  const state = isControlled ? on! : internalOn;

  function dispatch(action: ToggleAction) {
    const next = action.type === 'toggle' ? !state : action.on;
    const reduced = stateReducer ? stateReducer(next, action) : next;
    if (!isControlled) setInternalOn(reduced);
    onChange?.(reduced, action);
  }

  return (
    <button aria-pressed={state} onClick={() => dispatch({ type: 'toggle' })}>
      {state ? 'On' : 'Off'}
    </button>
  );
}

// Example: forbid turning off
<Toggle stateReducer={(next, action) => (action.type === 'toggle' ? true : next)} />

This pattern composes well across product needs without forking components.

Headless components and hooks‑first design

A “headless” component exposes behavior without UI. In React, custom hooks are a natural fit: return state, dispatchers, and prop getters. Consumers decide the markup.

function useDropdown() {
  const [open, setOpen] = React.useState(false);
  const buttonRef = React.useRef<HTMLButtonElement | null>(null);

  function getButtonProps(props: React.ButtonHTMLAttributes<HTMLButtonElement> = {}) {
    return {
      ...props,
      ref: buttonRef,
      'aria-expanded': open,
      onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
        props.onClick?.(e);
        setOpen((o) => !o);
      },
    } as const;
  }

  function getMenuProps(props: React.HTMLAttributes<HTMLUListElement> = {}) {
    return { role: 'menu', hidden: !open, ...props } as const;
  }

  return { open, setOpen, getButtonProps, getMenuProps } as const;
}

export function DropdownExample() {
  const { getButtonProps, getMenuProps } = useDropdown();
  return (
    <div>
      <button {...getButtonProps()}>Options</button>
      <ul {...getMenuProps()}>
        <li role='menuitem'>Edit</li>
        <li role='menuitem'>Delete</li>
      </ul>
    </div>
  );
}

Benefits:

  • Total visual freedom for consumers
  • Easier to test behavior independently of styling
  • Great building block for a design system

Polymorphic components with an “as” prop and forwardRef

Polymorphism lets a component render different underlying elements while preserving behavior and semantics.

type AsProp<C extends React.ElementType> = { as?: C } & Omit<React.ComponentPropsWithoutRef<C>, 'as'>;

const Button = React.forwardRef(function Button<C extends React.ElementType = 'button'>({
  as,
  className,
  ...props
}: AsProp<C> & { className?: string }, ref: React.Ref<Element>) {
  const Comp = (as || 'button') as React.ElementType;
  return <Comp ref={ref} className={`btn ${className ?? ''}`} {...props} />;
});

// Usage
<Button onClick={() => {}}>Primary</Button>
<Button as='a' href='/docs'>Link Button</Button>

This pattern composes nicely with accessibility (e.g., use as='a' for real links) and styling systems.

Data and boundary composition: Suspense, Error Boundaries, and Providers

Boundaries are compositional primitives too.

  • Suspense: isolate loading states around specific subtrees.
  • ErrorBoundary: contain and recover from failures locally.
  • Providers: configure cross‑cutting concerns (theme, i18n, data clients) per subtree.
function App() {
  return (
    <ThemeProvider>
      <ErrorBoundary fallback={<p>Something went wrong.</p>}>
        <React.Suspense fallback={<Spinner />}> 
          <Dashboard />
        </React.Suspense>
      </ErrorBoundary>
    </ThemeProvider>
  );
}

By composing boundaries, you prevent a single slow or broken widget from degrading the whole page.

Performance: composing for fewer re‑renders

Composition can improve performance when used thoughtfully:

  • Split providers: place context near consumers to limit update scope.
  • Memoize values: wrap expensive context values in useMemo and event handlers in useCallback.
  • Stabilize child identities: pass stable keys and memoized props to React.memo children.
  • Avoid over‑broad context: lift state only as high as necessary; prefer granular contexts.
  • Derive state lazily: compute derived data inside components that need it.

Example—granular context to avoid re‑render storms:

const CountContext = React.createContext<{ value: number; inc: () => void } | null>(null);

function CounterProvider({ children }: { children: React.ReactNode }) {
  const [value, set] = React.useState(0);
  const inc = React.useCallback(() => set((v) => v + 1), []);
  const ctx = React.useMemo(() => ({ value, inc }), [value, inc]);
  return <CountContext.Provider value={ctx}>{children}</CountContext.Provider>;
}

const CountValue = React.memo(function CountValue() {
  const ctx = React.useContext(CountContext)!;
  return <span>{ctx.value}</span>;
});

function IncrementButton() {
  const { inc } = React.useContext(CountContext)!; // only reads inc
  return <button onClick={inc}>+</button>;
}

IncrementButton won’t re‑render when value changes if inc is stable, reducing work.

Styling and composition

Styling systems often shape your composition strategy:

  • Utility‑first CSS (e.g., Tailwind): components stay “headless,” style via class props.
  • CSS‑in‑JS or variants: expose a className escape hatch and a style prop; consider a variant API for common cases.
  • Slot‑based styling: accept slotProps={{ header: { className: '...' } }} to style internals without breaking encapsulation.
type SlotProps = { header?: React.HTMLAttributes<HTMLElement>; body?: React.HTMLAttributes<HTMLElement> };
function Modal({ title, children, slotProps = {} as SlotProps }: { title: string; children: React.ReactNode; slotProps?: SlotProps }) {
  return (
    <div role='dialog' aria-modal='true'>
      <header {...slotProps.header}>{title}</header>
      <section {...slotProps.body}>{children}</section>
    </div>
  );
}

Testing composable components

Test behavior, not implementation details:

  • For compound components, render the full composition and assert on visible behavior.
  • Mock boundaries (e.g., providers) only when necessary.
  • Prefer user‑level assertions (roles, labels, keyboard) over DOM structure.
// Pseudocode with Testing Library
render(
  <Tabs defaultId='a'>
    <Tabs.List>
      <Tabs.Tab id='a'>A</Tabs.Tab>
      <Tabs.Tab id='b'>B</Tabs.Tab>
    </Tabs.List>
    <Tabs.Panel id='a'>Panel A</Tabs.Panel>
    <Tabs.Panel id='b'>Panel B</Tabs.Panel>
  </Tabs>
);
user.click(screen.getByRole('tab', { name: 'B' }));
expect(screen.getByText('Panel B')).toBeVisible();

Common anti‑patterns and trade‑offs

  • Over‑abstracting early: start with simple children and evolve APIs as real needs emerge.
  • Prop drilling where context fits better: when many levels pass the same data, introduce a provider.
  • Hidden side effects: don’t mutate children or rely on implicit ordering without documenting it.
  • Excessive HOCs: prefer hooks or composition to avoid wrapper pyramids.
  • Breaking accessibility with polymorphism: keep semantics correct (e.g., links must be <a> with href).

A practical checklist

  • Does each component or hook do one job?
  • Can consumers swap structure or styling without forking?
  • Are accessibility roles and keyboard interactions owned by the component?
  • Is there an escape hatch (render prop, slotProps, or a headless hook)?
  • Are performance characteristics reasonable under frequent updates?
  • Are the APIs consistent across the design system?

Closing thoughts

Composition is the language of React. By leaning on children, slots, render props, compound components, hooks, and boundaries, you can build UIs that are flexible, accessible, and resilient to change. Start simple, add structure where it pays off, and provide escape hatches so teams can adapt your building blocks to new requirements without breaking the whole system.

Related Posts