Strategies for mitigating prop drilling with React and TypeScript

How to repeat yourself a little less

12 min read 3617

Repeat yourself a little less: strategies for mitigating prop drilling with React and TypeScript

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.

The problem

Here’s a tiny React app:

demo app showing prop drilling 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:

We made a custom demo for .
No really. Click here to check it out.

Now there are TWO counters
Now there are TWO counters

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:

  • Pros — It’s extremely explicit! No magic here. Every component takes in exactly the props it needs
  • Cons — Maximum repetition. Requires changes in all components in the hierarchy

Solution 1: Use fewer components

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:

  1. Presumably, there was a reason you created all those intermediate components. Maybe they’re used in other parts of the application as well. If you inline them in multiple places (as we did with ABCounter), then you’re trading the repetition of threading for duplication of your UI code
  2. Inlining the components also eliminates the possibility of memoization. If whole chunks of your component hierarchy can be memoized, then you’ll save many unnecessary renders

That 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):

  • Pros — yields extremely concise, clear code with minimal type annotations
  • Cons — makes it harder to reuse components and removes possibilities for memoization

Solution 2: Use children

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 Counters 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:

  • Pros — Allows you to colocate components and their state while keeping reusable intermediate components
  • Cons — Requires that intermediate components be completely agnostic of their children

Solution 3: Combine related props into a single object

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:

  • Pros — Reduces repetition when you add or change properties on the object
  • Cons — Can result in more complex event handlers. May lead to “overforwarding” if a component doesn’t need all the properties in the object. Can make caching trickier

Solution 4: Use the Context API

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:

  • Pros —  Directly solves the issue of threading props through intermediate components. Comes built into React
  • Cons — Makes dependencies less explicit and prevents TypeScript from being able to track them. Brings in some boilerplate of its own

Solution 5: Pick props

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:

  • Pros — Retains explicit dependencies without having to modify intermediate components. Avoids repeating type declaration
  • Cons — Requires some fancy TypeScript constructs. Seeing a component’s props requires help from your editor. Doesn’t save as many lines of code as other approaches, at least in this example

Conclusions

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!

You come here a lot! We hope you enjoy the LogRocket blog. Could you fill out a survey about what you want us to write about?

    Which of these topics are you most interested in?
    ReactVueAngularNew frameworks
    Do you spend a lot of time reproducing errors in your apps?
    YesNo
    Which, if any, do you think would help you reproduce errors more effectively?
    A solution to see exactly what a user did to trigger an errorProactive monitoring which automatically surfaces issuesHaving a support team triage issues more efficiently
    Thanks! Interested to hear how LogRocket can improve your bug fixing processes? Leave your email:

    Full visibility into production React apps

    Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

    LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

    The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

    Modernize how you debug your React apps — .

    Leave a Reply