Prop drilling is one of the most common gripes with React. You add a prop or event handler in a component way down the hierarchy and you’re forced to add it to every parent component as well. It gets even worse if you’re using TypeScript (or PropTypes). Now you need to add types for the prop or event to every component in your hierarchy as well.
This post will explain the problem and walk through several options for mitigating it. Most of these approaches have been explained in other posts, but I think it’s helpful to see them all in one place with a common demo app. I haven’t seen the “pick props” approach before—if you have, please let me know! If you prefer to see the examples all together, check out this post’s companion GitHub repo.
Here’s a tiny React app:
You can find the full source code here. It’s not much, but it does show React’s incredible ability to keep multiple parts of the UI in sync with one another.
There are four components here App
, LeftColumn
, RightColumn
, and ACounter
. The App
component owns the state and renders the two columns:
export default function App() { const [a, setA] = React.useState(0); return ( <div className="root"> <h1>Prop Drilling</h1> <div className="columns" style={{ display: "flex" }}> <LeftColumn a={a} onSetA={setA} leftThing="I'm on the left" /> <RightColumn a={a} onSetA={setA} rightThing="I'm on the right" /> </div> </div> ); }
The left column renders its message and a counter. It passes state and event handler down to the counter:
interface LeftColumnProps { a: number; onSetA: (a: number) => void; leftThing: string; } function LeftColumn({ a, onSetA, leftThing }: LeftColumnProps) { return ( <div className="left" style={{ paddingRight: 10 }}> <h2>{leftThing}</h2> <ACounter a={a} onSetA={onSetA} /> </div> ); }
(The right column is similar.)
Finally, we have the counter, which renders a DOM element and calls the event handler when things change:
interface ACounterProps { a: number; onSetA: (a: number) => void; } function ACounter({ a, onSetA }: ACounterProps) { return ( <> a:{" "} <input type="number" style={{ width: "3em" }} value={a} onChange={(e) => onSetA(+e.target.value)} /> </> ); }
Since there’s only a single piece of state, the two counters in the two columns stay synchronized with one another. This is the beauty of React!
This simple example weighs in at 64 lines of code. And it already illustrates the hallmark of prop drilling, the LeftColumn
(and RightColumn
) components don’t use a
or onSetA
directly. Rather, they just pass them down to child components.
The situation gets worse if we add in another counter:
To handle this change, we can change ACounter
to ABCounter
and introduce a fifth component, a generic Counter
, to reduce repetition:
interface CounterProps { name: string; val: number; onSetVal: (val: number) => void; } function Counter({ name, val, onSetVal }: CounterProps) { return ( <> {name}:{" "} <input type="number" style={{ width: "3em" }} value={val} onChange={(e) => onSetVal(+e.target.value)} /> </> ); } interface ABCounterProps { a: number; onSetA: (a: number) => void; b: number; onSetB: (b: number) => void; } function ABCounter({ a, onSetA, b, onSetB }: ABCounterProps) { return ( <> <Counter name="a" val={a} onSetVal={onSetA} /> <br /> <Counter name="b" val={b} onSetVal={onSetB} /> </> ); }
To wire this up, we have to make changes in all the other components as well.
For LeftColumn
:
interface LeftColumnProps { a: number; onSetA: (a: number) => void; b: number; // NEW! onSetB: (a: number) => void; // NEW! leftThing: string; } function LeftColumn({ a, onSetA, b, onSetB, leftThing }: LeftColumnProps) { return ( <div className="left" style={{ paddingRight: 10 }}> <h2>{leftThing}</h2> <ABCounter a={a} onSetA={onSetA} b={b} onSetB={onSetB} /> {/* ^^^ Changed! ^^^ */} </div> ); }
We have to make the same changes to RightColumn
. And for App
:
export default function App() { const [a, setA] = React.useState(0); const [b, setB] = React.useState(1); // NEW! return ( <div className="root"> <h1>Prop Drilling</h1> <div className="columns" style={{ display: "flex" }}> <LeftColumn a={a} onSetA={setA} b={b} { /* NEW! */ } onSetB={setB} { /* NEW! */ } leftThing="I'm on the left" /> <RightColumn a={a} onSetA={setA} b={b} { /* NEW! */ } onSetB={setB} { /* NEW! */ } rightThing="I'm on the right" /> </div> </div> ); }
The app now clocks in at 99 lines of code. In addition to the Counter
changes and the addition of a new piece of state (b
), we had to add new props to two type declarations (LeftColumnProps
and RightColumnProps
), pass them through to ABCounter
in both components, and pass them down to both columns from App
. All in all, I count ten lines of changes across three files that are just passing data and handlers around. If you want to add a third counter, you’ll have to make even more changes across all the files.
This is the problem with prop drilling. The essence of this change was adding b
to the application’s state and adding the UI to display it. But almost half the change was passing props around in other components.
We’ll call this the “fully threaded” approach because the application state and handlers are passed all the way down. The remainder of this post looks at a few ways to pare back the repetition.
Fully-threaded:
Let’s call this the Kent C. Dodds approach since he suggests it in his classic article on prop drilling. Do we really need all these intermediate components? Here’s what it looks like if we keep Counter
but put everything else in App
, eliminating the LeftColumn
, RightColumn
and ABCounter
components:
export default function App() { const [a, setA] = React.useState(0); const [b, setB] = React.useState(1); return ( <div className="root"> <h1>Prop Drilling</h1> <div className="columns" style={{ display: "flex" }}> <div className="left" style={{ paddingRight: 10 }}> <h2>I'm on the left</h2> <Counter name="a" val={a} onSetVal={setA} /> <br /> <Counter name="b" val={b} onSetVal={setB} /> </div> <div className="right"> <h2>I'm on the right</h2> <Counter name="a" val={a} onSetVal={setA} /> <br /> <Counter name="b" val={b} onSetVal={setB} /> </div> </div> </div> ); }
This version of the app weighs in at a mere 46 lines of code. By eliminating the intermediate components and their props, we’re left with something even shorter than the original!
We’ve also entirely eliminated the problem of prop drilling because the Counter
components that show the state and generate the events are rendered in the same component (App
) that manages the state, there is no threading that needs to happen. No intermediate components are involved.
So is this the solution to the problem? Should we just smoosh all our components together? There are a few drawbacks to this approach:
ABCounter
), then you’re trading the repetition of threading for duplication of your UI codeThat being said, this approach does yield code that’s quite concise and clear. It also requires the fewest type annotations. So it’s worth considering if there are a few components that you can merge before you try the other solutions. Kent C. Dodds also has a great article on when to break up a component if you’re looking for more guidance.
Use fewer components (aka the Kent C. Dodds approach):
Combining components is so effective because it colocates the state and the components that use it, no threading necessary! But the downside is that you lose the ability to reuse your intermediate components elsewhere in your code.
There’s a middle ground though, which is using React’s children
prop. By doing this, we can keep our intermediate components (LeftColumn
and RightColumn
) and still, colocate the counters and the state.
Here’s LeftColumn
(as always, RightColumn
is similar):
interface LeftColumnProps { leftThing: string; children: React.ReactNode; } function LeftColumn({ leftThing, children }: LeftColumnProps) { return ( <div className="left" style={{ paddingRight: 10 }}> <h2>{leftThing}</h2> {children} </div> ); }
This accepts a children
prop which it renders. Here’s how you use it (in App
):
export default function App() { const [a, setA] = React.useState(0); const [b, setB] = React.useState(1); return ( <div className="root"> <h1>Prop Drilling</h1> <div className="columns" style={{ display: "flex" }}> <LeftColumn leftThing="I'm on the left"> <Counter name="a" val={a} onSetVal={setA} /> <br /> <Counter name="b" val={b} onSetVal={setB} /> </LeftColumn> <RightColumn rightThing="I'm on the right"> <Counter name="a" val={a} onSetVal={setA} /> <br /> <Counter name="b" val={b} onSetVal={setB} /> </RightColumn> </div> </div> ); }
This solution weighs in at 72 lines of code. By making the intermediate components accept children
, we can colocate the Counter
s and the state, just like we did in Solution 1. But unlike in Solution 1, we still have the intermediate components to reuse elsewhere in our app if we like.
The React docs discuss children under the theme of Composition vs. Inheritance. Passing children as props is an incredibly powerful tool, one that I’ve come to appreciate more and more over the years that I’ve used React. Children needn’t be DOM elements. For some great examples of non-DOM children check out React Router, which uses children to map Routes, and react-mapbox-gl, which uses children to define layers on an interactive Mapbox map.
Children:
If two props are frequently used together, you might consider combining them into a single object and passing that around instead. Here’s what ABCounter
looks like using that approach:
interface ABCounterProps { vals: { a: number; b: number }; onSetVal: (which: "a" | "b", val: number) => void; } function ABCounter({ vals: { a, b }, onSetVal }: ABCounterProps) { return ( <> <Counter name="a" val={a} onSetVal={(v) => onSetVal("a", v)} /> <br /> <Counter name="b" val={b} onSetVal={(v) => onSetVal("b", v)} /> </> ); }
Instead of passing a
and b
as separate props, we’ve combined them into a single vals
object. Instead of onSetA
and onSetB
props, we have a single onSetVal
prop which takes a which
parameter.
We can pass both of these through LeftColumn
:
interface LeftColumnProps extends ABCounterProps { leftThing: string; } function LeftColumn({ vals, onSetVal, leftThing }: LeftColumnProps) { return ( <div className="left" style={{ paddingRight: 10 }}> <h2>{leftThing}</h2> <ABCounter vals={vals} onSetVal={onSetVal} /> </div> ); }
Here I’ve used extends
with the type declaration to share the common props between ABCounterProps
and LeftColumnProps
.
Finally, App
:
export default function App() { const [a, setA] = React.useState(0); const [b, setB] = React.useState(1); const vals = { a, b }; const handleSetVal = (which: "a" | "b", val: number) => (which === "a" ? setA : setB)(val); return ( <div className="root"> <h1>Prop Drilling</h1> <div className="columns" style={{ display: "flex" }}> <LeftColumn vals={vals} onSetVal={handleSetVal} leftThing="I'm on the left" /> <RightColumn vals={vals} onSetVal={handleSetVal} rightThing="I'm on the right" /> </div> </div> ); }
The whole thing weighs in at 88 lines of code. Much more than when we smooshed all the components together, though definitely an improvement over the fully-threaded version. What’s more, if you add a c
counter, you only have to add three lines of code. Because the third counter will go in the same object, no changes are required to the intermediate components.
This approach works best for props that are always passed around together or modified together. Or for props that aren’t modified at all. If your application has some sort of global configuration that’s initialized once when it starts, that’s a great candidate for combining into a single object.
Combining props into objects:
React’s Context API is a form of dependency injection. It lets a parent component provide an object and a child component consume it, without the intermediate components having to know anything about it.
To use the context API, you first need to create a Context
object using React.createContext
.
We’ll use ABCounterProps
as a type:
interface ABCounterProps { a: number; onSetA: (a: number) => void; b: number; onSetB: (b: number) => void; } const ABContext = React.createContext<ABCounterProps>({ a: 0, b: 0, onSetA(a) {}, onSetB(b) {} });
Then, in App, we provide the context:
export default function App() { const [a, setA] = React.useState(0); const [b, setB] = React.useState(1); const context = { a, b, onSetA: setA, onSetB: setB }; return ( <div className="root"> <h1>Prop Drilling</h1> <ABContext.Provider value={context}> <div className="columns" style={{ display: "flex" }}> <LeftColumn leftThing="I'm on the left" /> <RightColumn rightThing="I'm on the right" /> </div> </ABContext.Provider> </div> ); }
And finally, in ABCounter
, we retrieve the context using React.useContext
:
function ABCounter(props: {}) { const { a, b, onSetA, onSetB } = React.useContext(ABContext); return ( <> <Counter name="a" val={a} onSetVal={onSetA} /> <br /> <Counter name="b" val={b} onSetVal={onSetB} /> </> ); }
No changes to LeftColumn
(or RightColumn
) were required, which is the whole idea of the context API. The ABContext
has teleported all the way down our component hierarchy. This solution weighs in at 91 lines of code.
Does this feel a little spooky? ABCounter
declares no props, but it clearly consumes something. If you fail to provide an ABContext
, the app won’t work. Similarly, if you try to render a LeftColumn
without providing the context, it will fail, too. These dependencies are critical, but they aren’t listed in the usual way (through props). TypeScript can’t help you, either. Failing to provide a required context to a child component may or may not be an error. It may just be that it’s provided by a parent component.
Context is often described as the solution to prop drilling, but the React docs go to great lengths to push you towards alternatives. Especially if you’re using TypeScript, consider your alternatives before jumping to context.
Context API:
This strategy recognizes that most props passed around an application aren’t unique. A component’s props tend to be a subset of the application state. A similar pattern holds for event handlers. If we define types for the application state (and the universe of event handlers), then we can “pick” props off that when we define individual components.
Here’s a type describing the overall state for the app:
interface AppState { leftThing: string; rightThing: string; a: number; b: number; onSetA: (a: number) => void; onSetB: (b: number) => void; }
(In practice you might want to separate out the event handlers.)
To define the props type for ABCounter
, you can use TypeScript’s built-in Pick
generic:
type ABCounterProps = Pick<AppState, "a" | "b" | "onSetA" | "onSetB">; function ABCounter({ a, onSetA, b, onSetB }: ABCounterProps) { return ( <> <Counter name="a" val={a} onSetVal={onSetA} /> <br /> <Counter name="b" val={b} onSetVal={onSetB} /> </> ); }
Mousing over this definition, you can see that we get all the correct types for a
, b
and the event handlers.
The list of props is also useful for the components that render ABCounter
because they can use it to pick the props that they pass down:
const abCounterProps = ["a", "b", "onSetA", "onSetB"]; function LeftColumn(props: LeftColumnProps) { return ( <div className="left" style={{ paddingRight: 10 }}> <h2>{props.leftThing}</h2> <ABCounter {..._.pick(props, abCounterProps)} /> </div> ); }
Here we use object spread (...
) and lodash’s _.pick
to pick off exactly the props that ABCounter
needs.
There’s some duplication between ABCounterProps
(the type) and abCounterProps
(the value). How can we get rid of that and keep them in sync? Remember that TypeScript types go away at runtime. So types and values are a one-way street, you can derive a type from a value, but not a value from a type. So let’s start with the value.
We’ll want to use a const assertion (as const
) to get a more precise type for abCounterProps
:
const abCounterProps = ["a", "b", "onSetA", "onSetB"] as const; // type is readonly ["a", "b", "onSetA", "onSetB"]
Without the as const
, the inferred type is simply string[]
. You can reference this type withtypeof abCounterProps
. To pull off types for the elements of a tuple type, you can use [0]
, [1]
, etc. But what we want is a union of all the element types. For this we use [number]
:
type T1 = typeof abCounterProps[0]; // type is "a" type T2 = typeof abCounterProps[1]; // type is "b" type T3 = typeof abCounterProps[number]; // type is "a" | "b" | "onSetA" | "onSetB"
This last type looks exactly like the union we passed to Pick
! So we can plug this in to get ABCounterProps
:
const abCounterProps = ["a", "b", "onSetA", "onSetB"] as const; type ABCounterProps = Pick<AppState, typeof abCounterProps[number]>;
If you add a prop toabCounterProps
, it will automatically get added toABCounterProp
. If the new prop isn’t part of AppState
, then you’ll get an error.
We can use a similar strategy for LeftColumnProps
. But rather than listing out all the props explicitly, we can spread the props from ABCounter
:
const leftColumnProps = [...abCounterProps, "leftThing"] as const; type LeftColumnProps = Pick<AppState, typeof leftColumnProps[number]>; function LeftColumn(props: LeftColumnProps) { return ( <div className="left" style={{ paddingRight: 10 }}> <h2>{props.leftThing}</h2> <ABCounter {..._.pick(props, abCounterProps)} /> </div> ); }
Finally, we can use leftColumnProps
to pick off props in App
:
export default function App() { const [a, setA] = React.useState(0); const [b, setB] = React.useState(1); const appState: AppState = { a, b, onSetA: setA, onSetB: setB, leftThing: "I'm on the left", rightThing: "I'm on the right" }; return ( <div className="root"> <h1>Prop Drilling</h1> <div className="columns" style={{ display: "flex" }}> <LeftColumn {..._.pick(appState, leftColumnProps)} /> <RightColumn {..._.pick(appState, rightColumnProps)} /> </div> </div> ); }
The nice thing about this approach is that if you add a new prop to ABCounter
, it will automatically get added to the Props type for the intermediate components (because of the array spread and Pick
), and it will automatically get passed down (because of the {..._.pick()}
). You only have to modify the consumer and the producer, not the intermediate components.
We’ve kept all the explicit dependencies from the fully threaded version. It’s just that we’ve tricked TypeScript and the JS runtime into adding them to the intermediate components for us, without our having to type anything. Because everything is explicit, TypeScript can still flag errors when you fail to pass a required prop.
Here’s the complete source for this approach, which weighs in at 93 lines of code.
Pick props:
Prop drilling can be a source of frustration in React, especially if you’re using TypeScript. But don’t let it get you down! There are many possible ways to avoid the “threading” that results in duplicative, boilerplate code associated with prop drilling.
If you’re running up against prop drilling, first ask whether you could use fewer components. If a component is only used in once place, you may want to inline it to reduce overhead. Factoring out distinct React components is often a good idea, but it’s not quite as good an idea as factoring out distinct functions.
If you can’t merge components, consider whether some of your intermediate components are acting primarily as layout components, or if some portion of them is primarily about layout. If so, you may want to use children
. Passing children to a component facilitates the colocation of components and their state, and can be an effective way to mitigate prop drilling.
If neither of these approaches works, you can look into consolidating related props into objects, using the Context API, or using the “pick props” pattern described in this post.
Are there other ways you avoid prop drilling? Please suggest them in the companion repo!
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
One Reply to "Strategies for mitigating prop drilling with React and TypeScript"
Great article! I especially liked solution 5 and have been doing something similar where I spread the prop types of child components across the parent prop types. However I usually just pass all of the props (that were passed to the parent0 to the child and use object destructuring to “select” props. However I like you solution better with Pick as you are only passing the props expected by the component, nothing more.