Tuesday, January 26, 2021

Best options for sharing state between components with React

I'm building a new web app in React, and so far I've been building everything with functional components and the useState and useEffect hooks. This is so much better than building with class based Components!  This worked fine at first but my application is getting a little complex and I now need to share state between components at different levels.

There are many different ways that you can do this now with React. I evaluated several different solutions and decided to write up explanations of the different approaches, with some code examples, and my rationale for why I chose or did not choose that solution.

useState is so simple and flexible, and easy to understand, so I'd like to find an approach that's as close to useState as possible. Ideally just a single line in a component to get the current value and be able to update the value. Local state is really simple and easy to understand, why shouldn't state shared across a few components be so simple?

After my analysis, I found some libraries that almost did what I want, and showed me the power of custom hooks. So I decided to write my own small library, make-shared-state-hook, to make shared state easy. It generates a custom hook for you that you can put in your components with one line, that returns the current value and a setter function just like useState! But don't take my word for how nice this library is. Please read the different options that I evaluated to understand them better so you can make an informed decision yourself for what approach to use.

Options considered and evaluated 

Option 1 - Prop drilling

Prop drilling is a simple approach where you create local state value at a high level component, and pass it down as a prop to children component that need it. This approach is great for simple applications - it's very explicit and there are no other libraries or magic involved - you see exactly where the data gets created and where it's used. I highly recommend prop drilling if you only need to pass state between 1 or 2 levels of components.

But, like my application that I am building, most applications become more complex and prop drilling becomes unwieldy for some situations. If you rely on prop drilling you'll find yourself passing props through several levels that don't actually need it themselves which is messy. And your list of props can become huge in the higher level components as they need to have everything that all of their children will need as an explicit prop. This will cause different developers working in different areas to trip over each other.  See the following code example:

As you can see here - CounterDisplay and SecondCounterDisplay don't actually need counter - yet they have to have it as a prop and pass it to the next component. And this is for just one piece of data - imagine how much more unwieldy this will become as more data is added.

Verdict - Prop drilling is great for just one level, but a poor choice for anything more than one level of passing. So it's definitely not an option for data that needs to be used in different parts of a complex application.

Caveat - I put this first because everyone should understand this approach and use it where possible. Locally creating state with useState, and passing it as a prop to a single level deep child component is definitely the simplest approach, and simple is good. If your components look fine with this approach, use it!  But... don't design your components specifically so you can pass props around. When you hit a point where you have components passing their own props to children and not actually using those props - that's when you need to move away from this approach.

Option 2 - React's context with providers

Context is typically talked about as the alternative to Redux if you really need to share state. It's designed for application wide settings like locale, logged in user, etc. To use context - you create a context object or value to be used at a high level location, and then wrap the highest level of your application that contains all components that need the context data with a provider component.

This approach is designed to make it easy to read the value many places, but setting the value needs to happen at the highest level component that has the provider wrapper by default. So this only makes sense for a few types of data. However, there is a way that you can get around this by making one of the pieces of data in context a function to set the state value. Within the provider, use useState to store the values. Here's a simple but complete example, partially based off of an approach from the React core team.

There's a lot to like about this approach. It's pretty easy to understand when looking at a component. It keeps data separate and isolated. However, I don't like needing to wrap the highest level containing all of the components needing the context in the provider. This seems like it would get unwieldy as you add more different types of shared state. I also don't like the amount of work required to add a new piece of shared state - you must add the provider, and then traverse up and edit a higher level component to wrap it with the provider.

Verdict - I feel this approach would get cumbersome as you add more than a few different pieces of shared data. And this would lead to you putting different pieces of shared state in to a single object (because that would be easier than making a new provider and wrapping another level in something new), making it unnecessarily complex and inefficient. I'd be much happier with this approach than Redux, but, as you're about to see, I feel there are better ways to share state.


Option 3 - use-global-hook

While searching for information, I found a really good blog article, State Management with React Hooks — No Redux or Context API, that explained how you could easily write a custom hook to share state. The blog author wrote a library, use-global-hook, that can easily be put in to your application.You simply call the globalHook function once in a shared state file, passing in an initial value and an object with functions to update the data, and then you can use this in components similar to useState! You can put this in as many components as you want and it will work great - any component can update and all components will get the update. Here's a complete example where I'll show how to use it with two separate pieces of data and multiple components that read and write each piece of data:

Verdict - This approach has almost everything I want! But there are a few things I don't like. Having a separate actions object that you have to pass in seems like an unnecessary abstraction, why not just expose the function to set the data, like the blog post shows an earlier version of this library did? You also cannot use simple types, you have to use objects. This is not like useState - I much prefer the simplicity of useState where you can pass anything to save as state. The way it's written seems to encourage a single global state which I am very much opposed to - why keep all state together when you can keep them separate? You can pretty easily have separate pieces of state by calling useGlobal in separate places, but forcing the data to be an object makes this seem counterintuitive.

Option 4 - make-shared-state-hook

The blog post in option 3 and use-global-hook are really close to what I want, but I really want something that works just like useState. From the blog post on use-global-hook, I saw just how easy it is to use custom hooks to accomplish this.  So I decided to create my own library to do this, make-shared-state-hook. Here is an example:

Create each piece of shared state:

And here's a full example using that:

Now this is what I want - something just like useState that I can use anywhere in the application in any number of components, that all come back to the same data. It meets all of my goals!

There is another library that does almost the exact same thing, react-shared-state-maker. I discovered this as I was publishing make-shared-state-hook.  Great minds must think alike I guess?  I still decided to make make-shared-state-hook, because I wanted to make sure the library has no dependencies. make-shared-state-hook only relies on your application having at least React 16.8 as a peer dependency, no other libraries are needed!

Verdict - This approach has everything I want and meets all of my goals so I am using this library. The simplicity and power of hooks is amazing and has has brought back my love of React after suffering through many years of Redux (see more about why I am not using Redux in a later option)

Caveat - just because this is simple and easy doesn't mean you should use it everywhere. Only make state shared between components when you really need it to be. Keep as much in local state with useState as possible and your application will be simpler, less buggy, and easier to maintain. This approach does make it so that when you need to share state, it's almost as easy as using local state. 

Options not seriously considered


Option 5 - MobX

I took a quick look at MobX. I think MobX could have some good applications if you don't have React everywhere, and if you have some extremely complex use cases for sharing your data. But in my opinion, it feels too complex for just about every use case. And I don't like breaking away from React for having shared state. So I didn't do a detailed analysis.

Option 6 - Redux

Redux is always an option. It's been around since 2015 and has been battle tested. Because of my experience with it, I am taking Redux off the table. My prior company starting using Redux back in 2015 and built most UI applications with it for 5+ years, and some of the biggest applications were worked on by dozens of different teams over those years. So I got to see first hand (as both a manager managing teams building applications with it and a coder building simple and complex applications with it) just how bad some of the problems with Redux are.

The biggest issue I have with Redux is that it's so hard to wrap your head around what is happening when you're looking at the component code. And if you use react-redux with connect, that makes it even harder to grasp what's happening. Let's say you're looking at the code for a low level component. You want to know where the data for the props comes from. So you have to look at the higher order component which is usually a separate file. Then look at a mapStateToProps function to see what state values it pulls. Then look at the reducer (typically a different file) to see what the state actually looks like. Then look for dispatches across the application that have an action (yet another thing to look at) that the reducer is looking for to see where data is being set. And all of these are typically in different files. Except it all comes back to a single place where you have to tell redux each reducer, and all the data is stored together in a single object. By this point you've probably forgotten the details of the component because you've had to look at code in at least 6 different files, each file with a different concept to wrap your head around. You can't exactly right click and tell your editor/IDE to find usages of the piece of state.

Redux fails at many other things I'm looking for - it takes a lot of code to make a new piece of shared data and use it (compare that to the 2 lines needed for make-shared-state-hook), your reducer code is burdened with having to account for all of the other state being there, bugs can easily slip in when state data changes and it's hard to find what's using it... I could go on for a long time about why not to use Redux. Now I'm sure there may be a few cases where you need a little more than what make-shared-state-hook offers, but, I'm not sure Redux is the answer.

Option 6a - React's useReducer with context

As much as I love how React added hooks in 16.8, I was really baffled by them adding useReducer and dispatch. To me, this is not really an option - you get most of the problems and complications of Redux, but you'll encounter some more problems that Redux had solved long ago.  

About the Author

I'm Brent Sowers, a principal software engineer at BlackSky. I've been writing code and managing software teams working with many different langues and frameworks for 20 years. Check out the links on the top right to find out more about me. And come work with me at BlackSky!