Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 013 / 100

Prop drilling is not always bad: lessons from production

Passing props two levels down is fine.

Expona Admin7 min read

Prop drilling is not always bad

I spent the first few months of my career treating prop drilling like a disease to be eradicated at all costs. Every time I passed props down two or three levels, I felt like I was committing a cardinal sin of React architecture. Then I built a large-scale e-commerce platform where I reached for context and Redux for nearly everything, and the codebase became harder to trace, not easier. That experience taught me that prop drilling is not always bad—in fact, it's often the right choice for short-term data flow.

Explicit data flow is easy to follow

When you pass props down through components, anyone reading your code can immediately see where data comes from and where it goes. This explicitness is underrated in modern React discussions, where abstraction layers are praised as best practice. Let me show you what I mean with a concrete example.

Say you're building a product details page. You fetch product data at the page level and need to display it in nested components:

tsx
// pages/product/[id].tsx
export default function ProductPage({ product }) {
  return (
    <>
      <ProductHeader product={product} />
      <ProductDetails product={product} />
      <ProductReviews product={product} />
    </>
  );
}

// components/ProductHeader.tsx
export function ProductHeader({ product }) {
  return (
    <>
      <h1>{product.name}</h1>
      <ProductPrice product={product} />
    </>
  );
}

// components/ProductPrice.tsx
export function ProductPrice({ product }) {
  return <span>${product.price}</span>;
}

In this two-level prop drilling, you can trace where product comes from without hunting through context providers or global state. If a senior developer joins your team next week, they'll understand this flow in seconds. No Redux devtools, no context tracing—just clear, obvious data flow.

When I worked on a ride-hailing platform, I maintained components that were drilling props through five levels, and that's when clarity started to break down. But two levels? Three levels in rare cases? That's perfectly readable and maintainable.

Composition often removes the need

Before reaching for context to avoid prop drilling, try composition. React's model of rendering functions as children or as render props can eliminate the drilling problem entirely without introducing abstraction.

Here's a common scenario: you have a modal that needs user data nested several levels deep, and you're tempted to use context to avoid drilling. Instead, try this:

tsx
// Before: tempted to use context
export function Dashboard() {
  const user = useUser();
  return <Modal user={user}><ModalContent /></Modal>;
}

// After: composition with children
export function Dashboard() {
  const user = useUser();
  return (
    <Modal>
      <ModalContent user={user} />
    </Modal>
  );
}

// Modal doesn't need to know about user
export function Modal({ children }) {
  return (
    <div className="modal-overlay">
      <div className="modal-box">{children}</div>
    </div>
  );
}

By passing user at the point where we render ModalContent, we skip the intermediate Modal component. It doesn't need to know about user data at all. This is composition, and it's more powerful than context in many cases because it keeps components decoupled.

On my trading platform project, we used composition patterns extensively to pass market data through layout components without making those layouts aware of trading logic. The result was reusable layout components and clear separation of concerns.

Use context at 4+ levels or for app-wide values

Context shines in two scenarios: when you're drilling props through four or more levels, and when you need to share values across your entire application. Below four levels, the clarity of prop drilling outweighs the convenience of context.

Here's how I think about it:

  • 1-3 levels: Use props. It's explicit and performant.
  • 4+ levels: Context is reasonable. Prop drilling becomes verbose.
  • App-wide values: Theme, user session, feature flags. These belong in context or global state.

Let me show a realistic example from a CRM system I built. The user's organization settings affect many components deep in the tree:

tsx
// This is perfect for context—it's app-wide
const OrgContext = createContext();

export function AppLayout({ children }) {
  const org = useOrgSettings();
  return (
    <OrgContext.Provider value={org}>
      {children}
    </OrgContext.Provider>
  );
}

// Deep inside the component tree
export function ContactCard() {
  const org = useContext(OrgContext);
  return (
    <div>
      <h3>Contact Details</h3>
      {org.showPhoneNumbers && <p>555-1234</p>}
    </div>
  );
}

Organization settings are truly app-wide. They change rarely and should be accessible from anywhere. This is a strong signal that context is the right tool. If instead you only needed org settings in one branch of your component tree, props would still be better.

Do not add global state to avoid three props

I've seen teams reach for Redux or Zustand to avoid passing three props down. That's premature abstraction, and it makes debugging harder, not easier. Every layer of abstraction has a cost, and that cost isn't worth paying just to skip three props.

Here's a real mistake I made early in my career:

tsx
// My old "solution": global state for a filter value
const filterSlice = createSlice({
  name: 'filters',
  initialState: { searchTerm: '' },
  reducers: {
    setSearchTerm: (state, action) => {
      state.searchTerm = action.payload;
    }
  }
});

// The problem: now I need Redux devtools to debug, the filter logic is
// spread across actions/reducers, and testing requires mocking Redux

Compare that to the simple prop approach:

tsx
// Better: local state + props
export function ProductPage() {
  const [searchTerm, setSearchTerm] = useState('');
  
  return (
    <>
      <SearchBar searchTerm={searchTerm} onSearch={setSearchTerm} />
      <ProductList searchTerm={searchTerm} />
    </>
  );
}

export function ProductList({ searchTerm }) {
  return <div>Products matching: {searchTerm}</div>;
}

This is testable without mocking, debuggable with a single browser tab, and requires no Redux mental model. The state is co-located with its consumers. Add global state only when you genuinely have multiple independent parts of your app that need to synchronize the same value.

On my SaaS platform, I tracked user notifications across the entire app. That went into context. I also had a "sort order" preference on a single reports page. That stayed as local state, and the sort component drilled it down to three children. Perfect trade-off.

Common mistakes

Confusing prop drilling with poor component design

Prop drilling isn't the same as bad architecture. You can have bad prop drilling (passing unrelated concerns through intermediate components) and good prop drilling (passing relevant data through a single chain). The answer isn't always context—sometimes it's to redesign which component owns the state.

tsx
// Bad drilling: why is Modal responsible for passing auth?
<Modal isOpen={isOpen}>
  <Form>
    <Input {...authProps} />
  </Form>
</Modal>

// Better design: Form owns auth concerns
<Modal isOpen={isOpen}>
  <AuthForm />
</Modal>

Using context for frequently changing values

Context has a known performance limitation: when context value changes, all consumers re-render. If you're updating a value hundreds of times per second (like mouse position or scroll offset), context causes performance issues. Use refs or local state in those cases, even if you have to drill props.

Creating too many small contexts instead of one cohesive context

I've seen codebases with UserContext, AuthContext, PermissionContext, and PreferencesContext when they should have been one UserContext with nested objects. Multiple small contexts make prop handling verbose again and lose the benefit of centralization.

Key takeaways

  • Prop drilling through two or three levels is normal and healthy. The explicitness is a feature, not a bug.
  • Before using context, try composition patterns—pass data where you render children, not through intermediate components.
  • Context is appropriate at 4+ levels of drilling or for genuinely app-wide values like theme or user session.
  • Never add global state or context just to avoid three props. That's over-engineering and makes your codebase harder to reason about.
  • A well-structured component tree with clear responsibilities needs less drilling than a poorly structured one with bloated intermediate components.
  • The best abstraction is the one that makes the code easier to read, test, and modify. For short data flows, that's usually props.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles