Skip to content

React · State and Data Flow

Prop Drilling and Composition in React — A Complete Guide

8 min read Updated 2026-06-24 Share:

Practice Prop Drilling and Composition interview questions

What prop drilling is and why it hurts

Prop drilling means passing data through multiple intermediate components that don't use it themselves — they just forward it so a deeply nested child can access it.

// `user` is needed only by Button, but it travels through every layer
function App()             { return <Page user={user} /> }
function Page({ user })    { return <Section user={user} /> }
function Section({ user }) { return <Widget user={user} /> }
function Widget({ user })  { return <Button user={user} /> }
function Button({ user })  { return <span>{user.name}</span> }

Page, Section, and Widget all carry a user prop they never read. This creates several problems:

  1. Coupling — every intermediate layer must be updated if the shape of user changes.
  2. Refactor friction — adding or renaming a prop means touching every layer in the chain.
  3. Bloated signatures — intermediate components accumulate pass-through props that obscure their real interface.
  4. Testing burden — tests for Page and Section must supply user even though those tests don't care about it.

The good news: prop drilling has multiple solutions, ordered from simplest to most powerful.

Solution 1: Co-locate state closer to the consumer

Before reaching for any pattern, ask: does this state really need to live so high up? Moving state down to the component that needs it eliminates the intermediate passes entirely.

// ❌ Modal state in App drills through three layers
function App() {
  const [isOpen, setIsOpen] = useState(false)
  return <Header isOpen={isOpen} onToggle={setIsOpen} />
}

// ✅ Modal state lives right next to the button that opens it
function Header() {
  const [isOpen, setIsOpen] = useState(false)
  return (
    <>
      <button onClick={() => setIsOpen(true)}>Menu</button>
      {isOpen && <MobileMenu onClose={() => setIsOpen(false)} />}
    </>
  )
}

This is the inverse of "lift state up" — only lift when siblings need to share state; keep it local otherwise.

Solution 2: Component composition with the children prop

React's children prop lets you pass pre-built JSX into a component. The parent builds the element with full access to its own data; the intermediary just renders it.

// The classic drilling problem:
// App has `user`, Button needs `user`, Layout/Section don't care

// ❌ Drilling
function App()   { return <Layout user={user} /> }
function Layout({ user }) { return <Section user={user} /> }
function Section({ user }) { return <Button user={user} /> }

// ✅ Composition — App builds Button directly
function App() {
  return (
    <Layout>
      <Section>
        <Button user={user} />   {/* App owns the user reference */}
      </Section>
    </Layout>
  )
}

function Layout({ children })  { return <main>{children}</main> }
function Section({ children }) { return <div>{children}</div> }
// Neither component knows about user

React calls this inversion of control: the top-level owner decides what gets rendered, and the structural components just provide layout.

Solution 3: Named slots for multiple content areas

When a component has distinct content regions (header, sidebar, body), use named props — each receiving a JSX element — instead of threading data through to sub-components.

function Layout({ header, sidebar, children }) {
  return (
    <div className="layout">
      <header className="top-bar">{header}</header>
      <aside className="side-bar">{sidebar}</aside>
      <main className="content">{children}</main>
    </div>
  )
}

// The caller builds each slot with its own data — Layout knows nothing
<Layout
  header={<NavBar user={user} />}
  sidebar={<UserMenu user={user} />}
>
  <Feed posts={posts} />
</Layout>

Layout is a pure structural component. It gets no data props at all. The caller controls all content by composing JSX into the slots.

Solution 4: Context API for cross-cutting concerns

Composition works well when you control the tree from the top. It gets awkward when:

  • The consumer is many levels deep inside a third-party library
  • The data is needed by many unrelated subtrees (authentication, theme, locale)
  • Composing JSX from the top would make the call site unreadable

In those cases, Context is the right tool:

const UserContext = createContext(null)

function App() {
  return (
    <UserContext.Provider value={user}>
      {/* No user prop anywhere — consumers read it directly */}
      <Header />
      <Main />
      <Footer />
    </UserContext.Provider>
  )
}

function Button() {
  const user = useContext(UserContext)
  return <span>{user.name}</span>
}

Context is best for infrequently changing global values. For data that changes often (like a live count), all consumers re-render on every change — which can be expensive.

Solution 5: Render props for injectable logic

A render prop is a prop whose value is a function that returns JSX. The component calls the function, passing internal state to the caller:

function MouseTracker({ render }) {
  const [pos, setPos] = useState({ x: 0, y: 0 })

  return (
    <div onMouseMove={e => setPos({ x: e.clientX, y: e.clientY })}>
      {render(pos)}
    </div>
  )
}

// The consumer decides what to render with the tracked position
<MouseTracker render={({ x, y }) => <Crosshair x={x} y={y} />} />

Render props share logic without a prop-drilling chain, but they nest deeply in complex cases. Custom hooks have mostly replaced render props for logic sharing in modern React.

Solution 6: Compound components for component families

The compound component pattern combines Context + composition to create a group of components that work together — like <Select> and <Option>, or <Accordion> and <Accordion.Item>.

const AccordionCtx = createContext(null)

function Accordion({ children }) {
  const [openId, setOpenId] = useState(null)
  return (
    <AccordionCtx.Provider value={{ openId, setOpenId }}>
      <div>{children}</div>
    </AccordionCtx.Provider>
  )
}

Accordion.Item = function Item({ id, title, children }) {
  const { openId, setOpenId } = useContext(AccordionCtx)
  const isOpen = openId === id

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

// Usage — no explicit state passing at all
<Accordion>
  <Accordion.Item id="faq-1" title="What is React?">
    A UI library for building component trees.
  </Accordion.Item>
  <Accordion.Item id="faq-2" title="What is JSX?">
    Syntactic sugar for React.createElement.
  </Accordion.Item>
</Accordion>

The caller gets a clean, declarative JSX API. State is shared via the implicit Context created inside Accordion.

Choosing the right solution

SituationBest approach
State used only in a subtreeCo-locate — move state down
Data needed by a deeply nested child you buildComposition / children
Multiple distinct content areas in a layoutNamed slots (props accepting JSX)
Data needed across many unrelated subtreesContext API
Logic to share without a hierarchyCustom hook (or render prop)
Group of related UI componentsCompound components
Frequently updating shared state, many consumersZustand / Redux

Start at the top of this list. Only move down when the simpler option doesn't fit.

TypeScript tip: spread rest props to avoid prop drilling on wrappers

When writing a component that wraps a native element, extend its HTML attributes and spread ...rest. This forwards every native prop without listing each one:

import { ButtonHTMLAttributes } from 'react'

interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  icon: string
}

function IconButton({ icon, ...rest }: IconButtonProps) {
  return (
    <button {...rest}>
      <i className={`icon-${icon}`} />
      {rest.children}
    </button>
  )
}

// Passes onClick, disabled, aria-label, type, etc. without drilling each
<IconButton icon="save" onClick={save} disabled={!dirty} aria-label="Save" />

Key interview points

  • Prop drilling is not inherently wrong — passing a prop one level down is fine. The problem starts at two or three layers of pass-through.
  • The first fix to try is co-location — move state closer to the consumer.
  • Composition (children, named slots) eliminates drilling by letting the owner build the element directly.
  • Context is for cross-cutting, infrequently changing data — not a replacement for any prop passing.
  • Compound components share implicit state between a parent and its child sub-components via Context, giving callers a clean JSX API.
  • Don't jump straight to Redux — work through this list in order.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel