Skip to content

Component Composition Patterns

advanced12 min read

Composition Over Memoization

The most elegant React performance solutions don't use memo, useMemo, or useCallback. They use composition — structuring components so that the component boundary itself prevents unnecessary re-renders.

// PROBLEM: ExpensiveTree re-renders every second
function App() {
  const [time, setTime] = useState(Date.now());

  useEffect(() => {
    const id = setInterval(() => setTime(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);

  return (
    <div>
      <Clock time={time} />
      <ExpensiveTree />  {/* Re-renders every second — WASTED */}
    </div>
  );
}

// SOLUTION: Move time state into Clock, pass ExpensiveTree as children
function App() {
  return (
    <ClockWrapper>
      <ExpensiveTree />  {/* Created here, never re-renders from clock */}
    </ClockWrapper>
  );
}

function ClockWrapper({ children }) {
  const [time, setTime] = useState(Date.now());
  useEffect(() => {
    const id = setInterval(() => setTime(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);

  return (
    <div>
      <Clock time={time} />
      {children}  {/* Same reference — React skips reconciliation */}
    </div>
  );
}

No imports added. No performance APIs used. Just moved the boundary.

The Mental Model

Mental Model

Think of component composition like package delivery. When a post office (parent component) re-sorts its mail (re-renders), it opens every package it packed itself and rechecks the contents. But packages that arrived already sealed from the sender (children props) are passed through without opening.

children (and any element passed as a prop) are "pre-sealed packages." The parent receives them and passes them to the output. When the parent re-renders, it doesn't re-create these elements — they're the same sealed packages from the previous delivery.

Pattern 1: Children as Props

The simplest composition pattern. Pass expensive components as children to a stateful wrapper:

// The stateful wrapper re-renders, but children don't
function AnimatedContainer({ children }) {
  const [isHovered, setIsHovered] = useState(false);

  return (
    <div
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      style={{ transform: isHovered ? 'scale(1.02)' : 'scale(1)' }}
    >
      {children}
    </div>
  );
}

// Usage: heavy content passed from stable parent
function ProductPage() {
  return (
    <AnimatedContainer>
      {/* These are created by ProductPage, not AnimatedContainer */}
      {/* When AnimatedContainer re-renders on hover, these are the */}
      {/* same element references — React skips their reconciliation */}
      <ProductImages />
      <ProductDetails />
      <ReviewSection />
    </AnimatedContainer>
  );
}
Execution Trace
Hover
setIsHovered(true)
AnimatedContainer re-renders
Children
children prop checked
Same reference as before (ProductPage didn't re-render)
Skip
Reconciliation skipped
ProductImages, ProductDetails, ReviewSection are NOT called
DOM
Only transform style updates
One CSS property change. Zero child component re-renders

Pattern 2: Render Props

When the parent needs to pass dynamic data to the children:

// The children need access to the mouse position
function MouseTracker({ render }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  const handleMouseMove = useCallback((e) => {
    setPosition({ x: e.clientX, y: e.clientY });
  }, []);

  return (
    <div onMouseMove={handleMouseMove} style={{ height: '100vh' }}>
      {render(position)}
    </div>
  );
}

// Only Cursor and Coordinates re-render when mouse moves
// The rest of the page is unaffected
function App() {
  return (
    <div>
      <Header />
      <MouseTracker
        render={(pos) => (
          <>
            <Cursor x={pos.x} y={pos.y} />
            <Coordinates x={pos.x} y={pos.y} />
          </>
        )}
      />
      <Footer />
    </div>
  );
}
Warning

The render prop pattern has a subtle issue: the render function creates new JSX on every call. To optimize, you can memoize the render function or use the children-as-function variant with useCallback. For most cases, the scope of re-renders is already limited to the render prop's output, which is the desired behavior.

Pattern 3: Compound Components

Components that work together, sharing implicit state:

// Compound component — internal state shared via context
const AccordionContext = createContext(null);

function Accordion({ children }) {
  const [openId, setOpenId] = useState(null);
  const value = useMemo(() => ({ openId, setOpenId }), [openId]);

  return (
    <AccordionContext.Provider value={value}>
      <div role="region">{children}</div>
    </AccordionContext.Provider>
  );
}

function AccordionItem({ id, title, children }) {
  const { openId, setOpenId } = useContext(AccordionContext);
  const isOpen = openId === id;

  return (
    <div>
      <button onClick={() => setOpenId(isOpen ? null : id)}>
        {title} {isOpen ? '▲' : '▼'}
      </button>
      {isOpen && <div>{children}</div>}
    </div>
  );
}

// Usage: clean API, internal state management
function FAQ() {
  return (
    <Accordion>
      <AccordionItem id="q1" title="What is React?">
        <p>A JavaScript library for building user interfaces.</p>
      </AccordionItem>
      <AccordionItem id="q2" title="What is a component?">
        <p>A reusable, self-contained piece of UI.</p>
      </AccordionItem>
      <AccordionItem id="q3" title="What are hooks?">
        <p>Functions that let you use state in function components.</p>
      </AccordionItem>
    </Accordion>
  );
}

The performance benefit: clicking an accordion item only re-renders the items that change state (the one opening and the one closing). The content inside non-affected items doesn't re-render.

Pattern 4: Slot Props

Pass specific elements as named props instead of children:

function Layout({ header, sidebar, footer, children }) {
  const [sidebarOpen, setSidebarOpen] = useState(true);

  return (
    <div className="layout">
      {header}     {/* Stable reference from parent */}
      <div className="content">
        {sidebarOpen && sidebar}  {/* Stable reference */}
        <main>{children}</main>   {/* Stable reference */}
      </div>
      {footer}     {/* Stable reference */}
      <button onClick={() => setSidebarOpen(!sidebarOpen)}>
        Toggle Sidebar
      </button>
    </div>
  );
}

// All slot elements are created by App (stable parent)
// Toggling sidebar doesn't re-render Header, Footer, or main content
function App() {
  return (
    <Layout
      header={<Header />}
      sidebar={<Sidebar />}
      footer={<Footer />}
    >
      <Dashboard />
    </Layout>
  );
}

Toggling the sidebar re-renders Layout but not its slot props, because they were created by App (which didn't re-render).

Production Scenario: The Modal Performance Fix

A team has a modal that re-renders the entire page content behind it:

// BEFORE: page content re-renders when modal state changes
function Page() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div>
      <ExpensiveDataGrid />  {/* 500 rows — re-renders on modal toggle */}
      <Chart />              {/* Heavy SVG — re-renders on modal toggle */}
      <button onClick={() => setShowModal(true)}>Open</button>
      {showModal && <Modal onClose={() => setShowModal(false)} />}
    </div>
  );
}

// AFTER: composition isolates modal state
function Page() {
  return (
    <div>
      <ExpensiveDataGrid />
      <Chart />
      <ModalSection />  {/* Manages its own state */}
    </div>
  );
}

function ModalSection() {
  const [showModal, setShowModal] = useState(false);

  return (
    <>
      <button onClick={() => setShowModal(true)}>Open</button>
      {showModal && <Modal onClose={() => setShowModal(false)} />}
    </>
  );
}

Opening/closing the modal now only re-renders ModalSection. The data grid and chart are untouched.

When Composition Isn't Enough

Composition solves most re-render problems but not all:

// Composition can't help when state MUST be at the top
// and MUST be passed to many children as different props
function DataDashboard() {
  const [data, setData] = useState(null);  // 20 widgets need this
  const [filter, setFilter] = useState('all');  // Affects all widgets

  return (
    <>
      <FilterBar filter={filter} onChange={setFilter} />
      {widgets.map(w => (
        <Widget key={w.id} data={data} filter={filter} />
      ))}
    </>
  );
}

Here, composition can't separate data state from the widgets — they all need it. This is where React.memo on Widget (with stable props) or external state management (zustand with selectors) becomes the right tool.

Common Mistakes

What developers doWhat they should do
Reaching for memo/useCallback before trying composition
Composition eliminates the problem structurally. memo/useCallback patches it with runtime checks — more code, more deps to maintain
Try moving state down or using children-as-props first. Composition is simpler and more maintainable
Creating children elements inside the re-rendering component
Elements created inside a component are new objects each render. Only elements passed from a non-re-rendering parent have stable references
Pass children from a stable parent for the reference stability benefit
Overusing compound components with context for simple use cases
Compound components add context overhead and complexity. They're worth it for complex shared-state UI (tabs, accordions, forms) but overkill for simple parent-child props
Use compound components when multiple parts share implicit state. For simple cases, regular props work fine
Assuming children-as-props always prevents re-renders
The optimization works because children have stable references from a non-re-rendering scope. If that scope also re-renders, references change
It prevents re-renders when the parent that creates the children doesn't re-render. If that parent also re-renders, children get new references

Challenge

Challenge: Refactor using composition

// This component re-renders ExpensiveChart
// every time the user expands/collapses the panel.
// Refactor using composition to prevent this.
// Do NOT use React.memo.

function Dashboard() {
  const [panelOpen, setPanelOpen] = useState(true);
  const data = useChartData();

  return (
    <div>
      <button onClick={() => setPanelOpen(!panelOpen)}>
        `{panelOpen ? 'Collapse' : 'Expand'}` Panel
      </button>
      {panelOpen && (
        <div className="panel">
          <PanelContent />
        </div>
      )}
      <ExpensiveChart data={data} />
    </div>
  );
}
Show Answer
function Dashboard() {
  const data = useChartData();

  return (
    <div>
      <CollapsiblePanel>
        <PanelContent />
      </CollapsiblePanel>
      <ExpensiveChart data={data} />
    </div>
  );
}

function CollapsiblePanel({ children }) {
  const [panelOpen, setPanelOpen] = useState(true);

  return (
    <>
      <button onClick={() => setPanelOpen(!panelOpen)}>
        {panelOpen ? 'Collapse' : 'Expand'} Panel
      </button>
      {panelOpen && (
        <div className="panel">
          {children}
        </div>
      )}
    </>
  );
}

State colocation: panelOpen moved into CollapsiblePanel. Now toggling the panel re-renders CollapsiblePanel and its children — but ExpensiveChart is a sibling of CollapsiblePanel in Dashboard, and Dashboard doesn't re-render, so ExpensiveChart is untouched. Additionally, <PanelContent /> is passed as children from Dashboard (stable reference), so it also doesn't re-render when the panel toggles.

Quiz

Quiz
Why do children passed as props not re-render when the receiving component re-renders?

Key Rules

Key Rules
  1. 1Children-as-props is the simplest composition pattern: pass expensive components from a stable parent to avoid re-renders without memo.
  2. 2Elements passed as props (children, slots, render props) maintain reference stability when the creating scope doesn't re-render.
  3. 3Compound components share implicit state via context. They offer clean APIs for complex stateful UI (tabs, accordions, forms).
  4. 4Composition eliminates re-render problems structurally. memo/useCallback patches them at runtime — composition is preferred.
  5. 5Slot props (header, sidebar, footer) let a layout component manage state without re-rendering its slot content.
  6. 6When composition isn't enough (state must be shared across many siblings), use memo on expensive components or external state managers.