DEV Community

loading...
Cover image for Rethinking Prop Drilling & State Management in React

Rethinking Prop Drilling & State Management in React

bytebodger profile image Adam Nathaniel Davis Updated on ・12 min read

My regular readers (both of them) know that I've been doing a lot of thinking (and rethinking) about state management and application architecture in React. It feels to me like the "standard" approach to React development is to spin up a fresh new project, and then, by default, to shackle that project to the epic scaffolding that is Redux.

In the first article in this series, I illustrated a particular technique I'd been using to get around "the Redux headache". In the second article, I explored (and was pleasantly surprised) by the capabilities of the new-and-improved Context API.

But I haven't stopped turning this one over in my head. And as much as I truly like the functionality that's available in the Context API, I'm definitely starting to rethink the whole idea that I need to somehow "get around" React's default approach at all (which is referred to, somewhat derisively, as "prop drilling").

The (Alleged) Problem

The first time I started reading through the React docs - before I'd written a single line of JSX - I was a little annoyed with the framework's default answer for sharing state/functions/anything. In fact, I think a lot of people had the same reaction when they first started learning about the framework. If they didn't, there wouldn't be so many React state-management tools/packages/articles/arguments out there "in the wild".

From where I sit, it seems that the React community has largely coalesced around an informal, unwritten policy that would read something like this:

Pass values via props whenever you have a direct parent/child relationship between components. For nearly anything else, reach for a state-management package, and dump all of the shared values into it. If you wanna earn the React Compliant-Developer Achievement Badge, use Redux. All the cool kids are doing it. You wanna be cool... don't you???

I believe that most devs reach for a state-management package because they want to avoid this potential headache:

export default class TopLevel extends React.Component {
   constructor(props) {
      this.state = {
         value1 : 'a',
         value2 : 'b',
         value3 : 'c',
         value4 : 'd',
      };
   }

   doThis = () => console.log('doThis()');

   doThat = () => console.log('doThat()');

   doSomethingElse = () => console.log('doSomethingElse()');

   render = () => {
      return (
         <>
            <div>Top Level</div>
            <MiddleLevel
               value1={this.state.value1}
               value2={this.state.value2}
               value3={this.state.value3}
               value4={this.state.value4}
               onThis={this.doThis}
               onThat={this.doThat}
               onSomethingElse={this.doSomethingElse}
            />
         </>
      );
   ;
}
Enter fullscreen mode Exit fullscreen mode

This is just a simple little component. It's primary purpose is to set a handful of state variables, declare a few basic functions, and then to render() the <MiddleLevel> component.

If there's any "problem" in this component, it comes from the fact that we're not using any state management tool. So if we need to pass all these values down to <MiddleLevel> (or to descendants further down the chain), we need to pass those values through props. And as our list of values to be passed downward grows, we start to acquire an unwieldy pile of props that have to be stuffed into <MiddleLevel>.

This can feel even more cumbersome if we have a <MiddleLevel> component that's something like this:

export default class MiddleLevel extends React.Component {
   constructor(props) {
      this.state = {value5 : 'e'};
   }

   doMiddleLevelStuff = () => console.log('doMiddleLevelStuff');

   render = () => {
      return (
         <>
            <div>Middle Level</div>
            <BottomLevel
               value1={this.props.value1}
               value2={this.props.value2}
               value3={this.props.value3}
               value4={this.props.value4}
               onThis={this.props.doThis}
               onThat={this.props.doThat}
               onSomethingElse={this.props.doSomethingElse}
            />
         </>
      );
   ;
}
Enter fullscreen mode Exit fullscreen mode

In this scenario, <MiddleLevel> isn't really doing anything with all those props that were passed into it. Well... that's not entirely true. <MiddleLevel> is doing one important thing: It's passing all those props onto its child - <BottomLevel> - where, presumably, <BottomLevel> knows what to do with them.

This is why I often think of prop drilling instead as: prop muling. Because we've essentially turned <MiddleLevel> into a pack mule by strapping all those unwieldy props onto its back.

(Note: I realize that you can remove a lot unnecessary typing here by using {...this.props}. But even if the verbiage is cleaner, the example shown above is still what's actually happening. The "muling" is still occuring. So I wanted to illustrate it manually for the purpose of highlighting the difference between this and my proposed solution.)

Of course, this is a simplistic scenario. There are plenty of examples in real code where a value near the top of a component hierarchy might need to be shared with components that are dozens of levels below it in the hierarchy. And no one wants to type out all those damn props on all of the intermediary components. That's why most React devs find themselves reaching for state-management solutions by default.

I will raise my hand and freely admit that, the first couple times I snooped around React as a potential tool for my own use, I saw this is a near-fatal flaw. And when I finally "gave in" and started doing real React development, I considered a state-management tool to be a necessity on any-and-all React projects. Because without a formal store in which I could chunk all of my shared values, I'd be faced with the prospect of doing massive prop drilling - and that was never something I was willing to consider.

Removing The Stigma From Prop Drilling

I've recently begun to reassess my aversion to prop drilling. It's not that I really want to write code that resembles the example shown above. That would feel to me like torture. But prop drilling does have a few major points in its favor:

  1. Prop drilling is core React.
    It's the way that the React docs first teach you to pass values between components. React devs (and JS devs, in general) love to pass around the word "pure". Pure functions. Pure components. Pure malarkey. (Just kidding... sorta.) Well, in React, you can't get much more "pure" than passing values through props.

  2. Props are innately stable.
    They won't be removed from React any more than arguments will be removed from JavaScript functions. They'll always be there. They'll always work as they do today. You'll never have to worry about whether your prop-handling code will be compliant with future versions of React. But if you're using a state-management tool, that tool may evolve and change over time. And you may be forced to refactor legacy state-management code that was previously working just fine as-is.

  3. Prop drilling has no package footprint.
    Obviously, if you're not using any state-management library, then there's no additional concern about package bloat.

  4. Adherence to props helps to write cleaner, purer functions/components.
    I'm still amazed by the number of React devs I encounter who don't understand (or simply won't acknowledge) that globalStateManagement === massiveDependencyInjection. They'll preach about the dangers of entangling dependencies... and then they slap Redux (or some other state-management tool) into nearly all of their components. But props have no such downside. Just as arguments are the API to a function, props are the API to a component. And APIs don't create dependencies. They insulate applications from dependencies, because they represent a standardized contract between that component and the rest of the app.

  5. Props enforce a uniform, traceable path of information through the app.
    Prop data can only flow in one direction: from parent-to-child. Yes, a child can invoke a method on the parent (through use of callbacks), but the data can only flow from ancestors down to descendants. But state stores that exist outside the traditional React structure provide devs a way to circumvent this flow. This can lead to some really nasty bugs.

Methods & Memory

But even if you agree with all of the points outlined above, it doesn't solve the laborious headache that awaits us if we choose to pass every value, through every component, in its own unique prop. At some point, you'll just give up and reach for your favorite, global, state-management tool. So if we strive to truly pass our values the "original" way - through props - the challenge becomes whether we can find ways to make that process manageable. Because any process that's inherently unmanageable will eventually be abandoned (or undercut in a very clunky way).

On my latest project, I'm using a technique that I've dubbed Methods & Memory. The idea's pretty simple. There are two types of values that we need to pass down through the hierarchy - methods (functions) and memory (state). If we can pass them down to the children without having to explicitly define every damn value, we can make the process much faster - and much cleaner.

The first objective is to combine "methods & memory" into their own composite objects. With single objects, we can pass the values without having to define loads of individual props. Of course, with regard to memory (state), those values are already packaged into one, convenient object for each component.

Memory

So with that in mind, we're gonna start to build a chain of uniformly-named props that will come to hold the shared state of the entire app. In other words, we're gonna use one, repeated prop to replace the common store that's provided by other state-management tools. In our <TopLevel> component, that will look like this:

export default class TopLevel extends React.Component {
   constructor(props) {
      this.state = {
         value1 : 'a',
         value2 : 'b',
         value3 : 'c',
         value4 : 'd',
      };
   }

   doThis = () => console.log('doThis()');

   doThat = () => console.log('doThat()');

   doSomethingElse = () => console.log('doSomethingElse()');

   render = () => {
      return (
         <>
            <div>Top Level</div>
            <MiddleLevel memory={this.state}
               onThis={this.doThis}
               onThat={this.doThat}
               onSomethingElse={this.doSomethingElse}
            />
         </>
      );
   ;
}
Enter fullscreen mode Exit fullscreen mode

There's nothing too radical here. There are other tools/techniques that call, from time-to-time, for you to pass a component's entire state down to a child. And that's what we're doing here. We're using the preexisting state object to pass all of this component's state through a single prop.

By doing this, we were able eliminate the distinct props that were used for value1, value2, value3, and value4 - because those values are already encapsulated in the state object. This makes the cognitive load of passing value1, value2, value3, and value4 much smaller because we don't have to spell them all out separately when a child component is rendered.

With our memory prop now created, we can continue to pass it down through multiple layers in the hierarchy... with one very-tiny exception. Here's what the memory prop will look like in the <MiddleLevel> component:

export default class MiddleLevel extends React.Component {
   constructor(props) {
      this.state = {value5 : 'e'};
   }

   doMiddleLevelStuff = () => console.log('doMiddleLevelStuff');

   render = () => {
      const {value1} = this.props.memory;
      if (value1 === 'a')
         console.log('This console.log() will be executed.');
      return (
         <>
            <div>Middle Level</div>
            <BottomLevel memory={{...this.state, ...this.props.memory}}
               onThis={this.props.doThis}
               onThat={this.props.doThat}
               onSomethingElse={this.props.doSomethingElse}
            />
         </>
      );
   ;
}
Enter fullscreen mode Exit fullscreen mode

First, I created a conditional console.log() to illustrate how we reference <TopLevel>'s state values from the props object.

Second, just as <TopLevel> passed its state to <MiddleLevel> via a single memory prop, so too will <MiddleLevel> pass the combined, global state to <BottomLevel> via a single memory prop. By consistently using the same prop name (memory) to pass shared state, it makes the code consistent whenever we need to access those values.

As you can see, the value of memory is slightly different than it was in <TopLevel>. Since <TopLevel> is the entry point for this demonstrated chain of components, there's nothing for <TopLevel> to pass except its own state. And that can be done quite simply with memory={this.state}.

But <MiddleLevel> wants to pass all the state. This means it must pass whichever values were supplied in this.props.memory in addition to any values that exist in its own state. So to accomplish this, we use spread operators to create a new value for memory that consists of the combined objects.

In theory, we can continue this chain for as long as we like. Every component receives the shared state values in this.props.memory and it passes them to its children with memory={{...this.state, ...this.props.memory}}. By following this pattern, all descendant components will have access to whatever state values were set on their ancestors.

Methods

Memory (state) was only one half of the equation in Methods & Memory. There are also times when you need to pass a function (e.g., a method), down to a child. You could stick function references right into the state object. That might feel a little "strange" for a lotta devs. So I've taken a different approach. Just as I pass memory (state) through the chain, I'll also pass methods (functions) in their own composite object. Here's what it looks like in <TopLevel>:

export default class TopLevel extends React.Component {
   constructor(props) {
      this.state = {
         value1 : 'a',
         value2 : 'b',
         value3 : 'c',
         value4 : 'd',
      };
      this.methods = {
         doThis : this.doThis,
         doThat : this.doThat,
         doSomethingElse : this.doSomethingElse,
      };
   }

   doThis = () => console.log('doThis()');

   doThat = () => console.log('doThat()');

   doSomethingElse = () => console.log('doSomethingElse()');

   render = () => {
      return (
         <>
            <div>Top Level</div>
            <MiddleLevel memory={this.state} methods={this.methods}/>
         </>
      );
   ;
}
Enter fullscreen mode Exit fullscreen mode

By passing all the state as a single object, we eliminated the need for individual props, to represent each of the individual state values, when rendering the child. And with this latest change, we've chunked all the function references into a single object. Then we pass that object in a single prop called methods. So we no longer need to pass every function in its own unique prop.

The net effect of these changes is that this:

<MiddleLevel 
   memory={{...this.state, ...this.props.memory}}
   methods={{...this.methods, ...this.props.methods}}
/>
Enter fullscreen mode Exit fullscreen mode

Is far cleaner, shorter, and more-standardized than this:

<MiddleLevel
   value1={this.props.value1}
   value2={this.props.value2}
   value3={this.props.value3}
   value4={this.props.value4}
   onThis={this.props.doThis}
   onThat={this.props.doThat}
   onSomethingElse={this.props.doSomethingElse}
/>
Enter fullscreen mode Exit fullscreen mode

So let's look at how we change <MiddleLevel> to support this:

export default class MiddleLevel extends React.Component {
   constructor(props) {
      this.state = {value5 : 'e'};
      this.methods = {doMiddleLevelStuff : this.doMiddleLevelStuff};
   }

   doMiddleLevelStuff = () => console.log('doMiddleLevelStuff');

   render = () => {
      const {value1} = this.props.memory;
      const {doThat} = this.props.methods;
      if (value1 === 'a')
         console.log('This console.log() will be executed.');
      doThat(); // prints 'doThat()' in the console
      return (
         <>
            <div>Middle Level</div>
            <BottomLevel 
               memory={{...this.state, ...this.props.memory}}
               methods={{...this.methods, ...this.props.methods}}
            />
         </>
      );
   ;
}
Enter fullscreen mode Exit fullscreen mode

Just as we did with memory, the methods prop passed into <BottomLevel> is a concatenation of this.props.methods (which contains any functions that have been passed down the chain) with this.methods (which contains the function references for the current component).

A Targeted Approach

This technique ultimately keeps a lot of control in the programmer's hands. As I've demonstrated it here, we're passing all of the state values from one component down to the next. But there's no rule that requires you to do as such.

You may have some state variables that you don't want to be shared around to all of the lower-level components in the hierarchy. And that's fine. Just don't include them in the memory prop. For the sake of brevity, I've illustrated the technique as using all of the this.state object. But you can always create your own abbreviated object, culled from the component's this.state object, that contains only the values you explicitly desire to be shared to the descendants.

Likewise, you probably don't want all (or maybe even, most) of your functions being shared down the hierarchy. The easy answer to such a concern is: Just don't add those function references to this.methods. You may not like the idea that you have to manually add those references into this.methods, but I honestly see that as a feature of this technique - not a flaw. This allows you to think deliberately about what does-or-does-not go into the shared state.

You probably have some components that don't have any need to access their ancestors' state or functions. That's fine, too. Just don't pass the memory and/or methods props to those components.

I know that some disagree with me on this. But targeted control is a must for any approach that I choose to use. I've seen Redux implementations where they've basically crammed ALL THE THINGS!!! into the global store. Redux doesn't make you do that. But some devs take that approach, nonetheless. I personally prefer any solution that encourages me to think, very carefully, about what I choose to put in shared stated and what remains "private".

Naming Collisions

The only obvious issue that I've found with this approach so far, is the danger of naming collisions. For example, you could have five different components in your hierarchy that all have a state variable named fieldValue. But if you're passing the state as I've shown above - by spreading the ancestors' state into the same object along with this component's state - there is the potential for naming conflicts. So keep that in mind if you choose to play around with this approach.

Avoiding Knee-Jerk Architectural Decisions

There's nothing wrong with the idea of using a state-management tool. They certainly have valuable, legitimate uses. There's nothing wrong with having a hammer in your toolbelt. Who doesn't appreciate a good hammer?? IMHO, a "problem" only occus when you insist on using that hammer on every project for every task.

I used to think that shared state management was the hammer that should be used for every task. I don't much care if you choose to use the technique I've outlined above, or whether you use the Context API, or whether you gaze lovingly upon your (mountain of) Redux code. The only thing that I care about (for any project that I'm directly involved in), is that we don't make dogmatic decisions based on some lazy assumption - like the assumption that passing shared values through props is somehow unmanageable.

Another aspect of this solution that I enjoy is that it's not an all-or-nothing atomic approach. You don't need to make any Big Hairy Global Project Decisions where your team argues for three months, and then decides to either throw out their state-management tool and switch to this solution, or only use their state-management tool and forsake this solution.

This is just the core functionality in React. It could live quite happily side-by-side with your existing state-management library. You could leverage the technique wherever/whenever you wish and you don't have to try to convince anyone to chuck their Redux/MobX/whatever in the garbage bin.

As I write these posts and do my own mental processing, not only have I started to realize that shared state management tools are not a universal must-have. But I'm even coming around to the idea that they should probably be the exception, rather than a default tool that's tacked onto every new React project.

Discussion (5)

pic
Editor guide
Collapse
isaachagoel profile image
Isaac Hagoel

I am not sure why getting props via something like 'mapStateToProps' is inferior to getting them via N levels for ...props (or ...props[Foo]).
The component getting the props exposes the same interface. Tracing the origin of a given prop is probably easier when it comes from the global state.

Collapse
bytebodger profile image
Adam Nathaniel Davis Author • Edited

Simply using mapStateToProps is not, by itself inferior. Tacking on all the additional boilerplate that comes standard with Redux, IMHO, is. But the point wasn't so much to rail against Redux. The point was that many people (myself included) have believed that it's simply impractical to pass values/states through props. The point is that passing values through props isn't necessarily as inferior as many React devs assume it to be.

There are certainly times to reach for a state management tool. But I believe firmly that, as JS/React devs, we shouldn't be so quick to reach for additional tools/libraries when the core language already does that function.

If you want to loop through an array in a for/each manner, you can load up jQuery, or Lodash, or Underscore. They all have loop-through-array functions. And there was a time when those functions were more practical. But now, with the Array.prototype functions, it would be really silly to load up those tools just to loop through an array.

I have other... "issues" with Redux. It's not just a state management tool (like MobX). It's an entire framework tacked onto the top of another framework (React). And it has the effect of scattering your application/business logic far away from the place where it's needed/used. But those points are best reserved for a future post. My original point wasn't to dissect Redux's features/challenges, but rather to illustrate that prop drilling doesn't have to be a headache.

Collapse
bytebodger profile image
Adam Nathaniel Davis Author

Although I don't expect this point to cause any Redux fans to throw it out, I also wanna reiterate the point made in the post regarding information flow. Any global state manager messes with that flow. Redux tries to make up for that problem by dragging all of the update logic into separate files/folders, treating reducers and actions as Gatekeepers of the Variables. But this provides a lot of cognitive overhead, literal (LoC) overhead, and it still doesn't change the fact that a global variable can be set from anywhere that has access to the global scope.

But by at least considering the idea that global state management is always an absolute necessity, it can make logic/data flow easier to trace. It's too much for me to illustrate that here in a reply, though.

Collapse
isaachagoel profile image
Isaac Hagoel

I get your points about state managers in general, not adding tools and libraries that aren't needed etc.
It just reads like you are saying: "prop drilling is a problem, that's why we have all of those other solutions like state managers" and then you say "well, actually it is not a problem".
From my experience, it is a problem no matter how you structure your props.
imho, the design decision that props are the only way for components to communicate and that they can only flow down the tree and only one level at a time is very arbitrary and doesn't suit real world applications very well. In other words, I pretty much agree with the first half of your argument as I understand it (that props drilling is indeed a problem).

Thread Thread
bytebodger profile image
Adam Nathaniel Davis Author • Edited

Well, then I suppose I didn't make my argument persuasively enough. :-)

But to be honest, I don't expect to be able to persuade too many people. Especially not in a single post. And especially not those people who already use state management tools, are deeply ingrained in them, and, most likely, probably love them. Hell... I've been that guy (even if my go-to state management tool-of-choice is MobX).

This is only a realization that I've come to over several years. And I freely admit that many other React devs might never come to the same realization.

If my team were starting a new "green fields" project, I wouldn't really argue much at all with someone who says, "And we totally have to use MobX." I might suggest that we consider the Context API. Depending on the scope of the project, I might even oh-so-gently suggest that maybe we don't need a state management tool at all. But that's not a hill I'm gonna die on.

The only recommendation I'd actively argue against is Redux. But quite frankly, I have far bigger reasons for disliking Redux (which I assume I'll outline in some future post). And even then, my argument certainly would not be, "Let's ditch Redux and just use prop drilling!" My argument would me more like, "If we're adamant on using a state management tool, why wouldn't we at least consider MobX??" But more importantly, if shared state management is that important, why wouldn't we first consider React's native solution to the problem: the Context API???