Prop Drilling
Prop drilling is what happens when you pass data through several layers of components using props, purely so that a deeply nested component can use it — even though the components in between never touch that data themselves. It’s a direct, unavoidable side effect of how React moves data: straight down, one direction, from parent to child. Prop drilling isn’t a bug or an anti-pattern by itself, but as component trees grow, it can turn a simple data change into a tedious, error-prone edit across many files. This lesson explains why it happens, when it’s fine, when it hurts, and the patterns React offers to avoid it.
Overview / How it works
React’s data flow is one-directional: a parent can pass data down to a child via props, but a child can never reach up and grab something from an ancestor directly. State lives wherever you declare it with useState (or useReducer), and the rule of thumb is to keep state in the closest common ancestor of every component that needs it. That’s usually good advice — it keeps a single source of truth and avoids duplicated, out-of-sync copies of the same value.
The problem shows up when the component that needs the data is several levels below the component that owns it. Plain props give you no way to skip a level. If App owns a piece of state and a component five layers down needs to read or update it, that value has to be threaded through every single component in between as a prop, purely so it can be forwarded along. Those intermediate components don’t use the value for anything — they just declare it in their props and pass it straight through to their own child. This threading is what "prop drilling" refers to.
It’s worth being precise about what drilling does and doesn’t cost you at render time. A component re-renders when its own state changes, when its parent re-renders (by default, every function component in the render path re-renders when its parent does, unless the component is wrapped in memo), or when context it subscribes to changes. Drilling a prop through an intermediate component doesn’t by itself trigger extra renders beyond what would already happen — the real cost is structural, not performance: every intermediate component’s signature and body must know about a piece of data it has no actual use for, which makes the codebase more fragile. Add a new drilled value, rename one, or reorder components, and you must touch every file in the chain, not just the two that actually care about the data.
For a prop threaded one or two levels deep, this is completely fine — often it’s the simplest, most explicit, most traceable option available, and you should not reach for anything fancier. It becomes a real maintenance problem once you’re threading several unrelated props (data values and callback functions) through three or more layers of components that have nothing to do with that data. That’s the practical signal to reach for component composition or, for truly global data like the current theme, logged-in user, or locale, the Context API (covered in the next lesson).
Syntax
Prop drilling has no special syntax of its own — it’s just ordinary props, repeated at every level of the tree:
function GrandParent() {
const [value, setValue] = useState(initialValue);
return <Parent value={value} setValue={setValue} />;
}
function Parent({ value, setValue }) {
return <Child value={value} setValue={setValue} />;
}
function Child({ value, setValue }) {
return <GrandChild value={value} setValue={setValue} />;
}
function GrandChild({ value, setValue }) {
return <button onClick={() => setValue(value + 1)}>{value}</button>;
}
- Owner component — the component that calls
useState(or similar) and truly owns the data. - Intermediate components — every component between the owner and the consumer; each one must declare the prop in its parameter list and pass it to its own child with the same name.
- Consumer component — the deeply nested component that actually reads or calls the drilled value.
- Both plain data (strings, numbers, objects, arrays) and functions (state setters, callbacks) can be drilled — drilling a callback like
setValueis just as common as drilling a value. - Prop names must match exactly at every hop; a typo or a missed level silently breaks the chain (see Common Mistakes).
Examples
Example 1: a simple two-level drill
import { useState } from "react";
function App() {
const [userName] = useState("Ava Patel");
return (
<div className="app">
<Profile name={userName} />
</div>
);
}
function Profile({ name }) {
// Profile doesn't use `name` itself — it only forwards it to Avatar
return (
<div className="profile">
<Avatar name={name} />
</div>
);
}
function Avatar({ name }) {
const initials = name.split(" ").map((part) => part[0]).join("");
return <div className="avatar">{initials}</div>;
}
export default App;
This renders a small avatar box showing the initials AP. App owns userName and passes it to Profile as name. Profile never reads name for its own rendering — it exists purely as a layout wrapper — but it must still accept the prop and forward it to Avatar, which is the component that actually needs it. At two levels this is harmless and easy to read.
Example 2: a deeper, more realistic drill
import { useState } from "react";
function App() {
const [theme, setTheme] = useState("light");
const handleLogout = () => alert("Logged out");
return <Layout theme={theme} onLogout={handleLogout} />;
}
function Layout({ theme, onLogout }) {
return (
<div className={`layout ${theme}`}>
<Sidebar theme={theme} onLogout={onLogout} />
<main>Dashboard content</main>
</div>
);
}
function Sidebar({ theme, onLogout }) {
return (
<aside className={`sidebar ${theme}`}>
<UserMenu onLogout={onLogout} />
</aside>
);
}
function UserMenu({ onLogout }) {
return <button onClick={onLogout}>Log out</button>;
}
export default App;
This renders a light-themed dashboard layout with a sidebar containing a "Log out" button; clicking it shows a browser alert reading "Logged out". Notice that theme is genuinely used by both Layout and Sidebar for their class names, but onLogout is pure passthrough baggage in both — neither component calls it or cares what it does, they just relay it to the next component down. If you needed to rename onLogout or add a second callback like onSettings, you’d have to edit all four components, not just App and UserMenu.
Example 3: removing the unnecessary drilling with composition
import { useState } from "react";
function App() {
const [theme, setTheme] = useState("light");
const handleLogout = () => alert("Logged out");
return (
<Layout theme={theme}>
<Sidebar theme={theme}>
<UserMenu onLogout={handleLogout} />
</Sidebar>
</Layout>
);
}
function Layout({ theme, children }) {
return (
<div className={`layout ${theme}`}>
{children}
<main>Dashboard content</main>
</div>
);
}
function Sidebar({ theme, children }) {
return <aside className={`sidebar ${theme}`}>{children}</aside>;
}
function UserMenu({ onLogout }) {
return <button onClick={onLogout}>Log out</button>;
}
export default App;
This renders identically to Example 2, but onLogout is no longer threaded through Layout or Sidebar at all. App builds the <UserMenu onLogout={handleLogout} /> element directly and hands it down as children; Sidebar just renders whatever it’s given without knowing onLogout exists. theme is still drilled because Layout and Sidebar genuinely consume it for styling — composition removes drilling only for the data that intermediate components don’t actually use, not for data they legitimately need.
Under the hood / step by step
On mount, React starts at the root and works downward: it calls App, gets back a tree of React elements, then calls each child component function with the props object described by its JSX, all the way down to the leaves. In Example 2, that means React invokes App → Layout({ theme, onLogout }) → Sidebar({ theme, onLogout }) → UserMenu({ onLogout }) in sequence, building up the actual DOM as it goes.
On a state update — say setTheme("dark") is called — React schedules a re-render starting at App, the component that owns the state. Because none of Layout, Sidebar, or UserMenu are wrapped in memo, React re-invokes all of them by default, passing each a fresh props object. Every intermediate component’s function body runs again, including the ones that only forward onLogout without using it — that’s the invisible tax of drilling: it doesn’t add renders you wouldn’t already have, but it does mean unrelated components keep re-executing code that only exists to shuttle a value past them.
On unmount, React tears the whole subtree down; any drilled callbacks simply stop being referenced. There’s nothing special about cleanup here unless one of the intermediate or leaf components also uses useEffect, in which case normal effect cleanup rules apply independent of drilling.
Common Mistakes
Mistake 1: forgetting to forward a newly added prop at one level
When you add a new prop at the top of a drilled chain, it’s easy to forget to update one of the middle components, leaving the final consumer with undefined.
function Profile({ name }) {
return (
<div className="profile">
{/* forgot to pass avatarUrl here */}
<Avatar name={name} />
</div>
);
}
function Avatar({ name, avatarUrl }) {
// avatarUrl is undefined here because Profile never received or forwarded it
return (
<div className="avatar">
<img src={avatarUrl} alt={name} />
</div>
);
}
App was updated to pass avatarUrl to Profile, but Profile‘s own JSX was never updated to forward it to Avatar, so the <img> renders with a broken src. Fix it by threading the prop through every hop:
function Profile({ name, avatarUrl }) {
return (
<div className="profile">
<Avatar name={name} avatarUrl={avatarUrl} />
</div>
);
}
function Avatar({ name, avatarUrl }) {
return (
<div className="avatar">
<img src={avatarUrl} alt={name} />
</div>
);
}
Mistake 2: mutating state after drilling the setter down
Drilling a setter function is common, but it’s easy to mutate the existing value in the deeply nested component instead of producing a new one, which breaks React’s ability to detect the change.
function TodoList() {
const [todos, setTodos] = useState(["Buy milk", "Walk dog"]);
return <TodoItems todos={todos} onAdd={(text) => addTodo(todos, text, setTodos)} />;
}
function addTodo(todos, text, setTodos) {
todos.push(text); // mutates the existing array — same reference goes back into setTodos
setTodos(todos);
}
Because todos.push mutates the array in place and setTodos(todos) passes back the exact same reference, React’s shallow comparison sees "no change" and may skip re-rendering. Always build a new array or object instead:
function addTodo(todos, text, setTodos) {
setTodos([...todos, text]);
}
Best Practices
- Keep state as close as possible to where it’s used; only lift it up to the nearest common ancestor that genuinely needs it — don’t lift "just in case."
- For one or two levels of drilling, just drill. It’s explicit, easy to trace, and doesn’t need Context or extra abstraction.
- Use the
childrencomposition pattern so a parent can hand a fully-built element to a component that never needs to know what data lives inside it. - Treat "a prop threaded through three or more components that don’t use it" as the practical signal to reach for Context or a state library, not a hard rule you must apply everywhere.
- Drill only the specific values a component needs (e.g.
user.name) instead of a whole object, to keep coupling and the re-render surface small. - Keep prop names identical at every level of the chain so it stays easy to trace and safe to refactor with a project-wide search.
- Never mutate a drilled state value or setter’s underlying data directly — always create a new array or object, even many levels down from where the state was declared.
Practice Exercises
- Build a three-level tree,
App→Card→Title, whereAppowns a heading string in state andTitlerenders it. Then refactorCardto use thechildrencomposition pattern instead of drilling the string through it. - Take the Dashboard example from this lesson and add a new drilled prop,
username, fromAppdown toUserMenu, rendering "Welcome, {username}". Notice how many files you had to touch to wire up one new value. - Create a
countstate and anincrementcallback drilled three levels down to aButtoncomponent, then refactor using composition so only the direct parent ofButtonneeds to know aboutincrement.
Summary
- Prop drilling is passing data down through every intermediate component so a deeply nested component can use it, even though the intermediates don’t use it themselves.
- It’s a direct consequence of React’s one-way, top-down data flow — there’s no way to skip a level with plain props.
- It’s fine, even preferable, for one or two levels; it becomes a maintenance burden as more props and callbacks get threaded through more unrelated components.
- Component composition (the
childrenprop) removes drilling for components that only render data, not consume it. - For truly global or deeply shared data (theme, current user, locale), the Context API is the next tool to reach for, covered in the following lesson.
- Always keep state updates immutable — create new arrays and objects — even when the setter has been drilled several components deep.
