TL;DR The useReducer React hook provides a Redux-like means of managing state transitions, but it’s no replacement for Redux when it comes to managing a global application state tree. It’s super useful at a lower level in the component hierarchy for handling pieces of state that are dependent on one another, instead of declaring a bunch of useState hooks, though.

Shortly before it was released, I watched an exciting introduction to the React Hooks API presented by Sophie Alpert and Dan Abramov at ReactConf 2018. Hooks scratch a lot of different itches for a cantankerous React dev such as myself, who has grumbled about React’s incomplete use of classes for quite some time now: they allow functional components to step into the limelight as stateful, side effect executing, context consuming, first-class citizens of a React application. Even better, hooks can be composed into larger units of functionality that can be mixed into any number of components.

The part of the Hooks API that stood out to me the most was not one of its more heavily promoted features like useState or useEffect, however. As I dug into the reference docs, I came across an intriguing hook named useReducer. It turns out that React now has the ability to use pure functions to handle state transitions built right in.

Despite the occasional hype about this or that library or React feature being a “Redux Killer,” none of them has yet unseated the champion in the Darwinian battle royale that is the React ecosystem. I also believe that it’s best to remain critical of including dependencies that can have such a dramatic impact on your application, though, and that means being aware of the alternatives. With this in mind, I decided to explore the possibility of using a useReducer hook to manage global application state.

Reductio Ad Absurdum

Depending on your experience with functional programming and libraries like Redux, you may or may not understand why I was interested in exploring the useReducer hook as an alternative, so let’s back up and talk about Redux bit:

Let me begin by saying that I think Redux is great. I have zero issues with the library itself. My gripes lie more in the PEBKAC department, with the developers who implement it either improperly or unnecessarily.

Redux took me some time to wrap my head around because it’s a solution to a complex problem, and it uses some functional programming concepts to great effect. It’s a very efficient JS library for tracking and making predictable changes to an application’s state. If that doesn’t make sense, LogRocket does an awesome job explaining when and why you’d use Redux in your application.

In Redux, interaction with the UI generates Actions, which are dispatched to a Store that holds onto a big object representing your app’s state. The Store passes the current state and the Action object through a function called a Reducer (or several reducers composed together), which does something to produce a brand new state object.

The reducer is a pure, deterministic function – that is, a function that relies on no stateful information outside of the arguments it’s fed and produces no side effects like HTTP requests, manipulating content on the page, playing sounds, etc. The result it returns for a given set of arguments is predictable.

Redux state lifecycle

Redux state lifecycle

Redux applications introduce the concept of two different types of components: containers and UI components. Containers are higher-order components that talk to the store and pass pieces of its state as props down to the UI components, which keep track of little or no local state themselves.

This is great for performance, because it allows us to pass down just the pieces of app state that our components require, which cuts down on unnecessary re-renders when unrelated pieces of the state change. However, It also increases the number of components we need to build and maintain.

I’ve seen plenty of examples of poorly planned applications that either make a container for every minuscule UI element, use overly complex containers that have their own internal state and UI logic in addition to forwarding state from the store, or use too few container components and force anyone maintaining the application to trace props passed down through a rat’s nest of nested components in order to get new state data to its intended destination in the UI.

Deciding what to wrap in a container is completely at the developer’s discretion. We do our best to structure things to make future updates possible, but we can’t always predict how an application may need to change in the future. I’ve worked on apps with each of these problems in the last couple of months, and it led me to wonder if there might be a different way to approach managing global state for relatively simple apps.

Hooksmanship

Captain Hook

This is what peak hook mastery looks like

Using the Hooks API, we can apply the same basic functional programming concepts that Redux uses to transform state without introducing additional dependencies or creating double the components. Let’s take a look at how we can combine contexts and the useReducer hook to create a store that all of the components in a React app can talk to.

In a Redux application, our store would be provided to our components by wrapping them in a Provider component that comes from the react-redux module. Provider functions as a context, which allows any of the components nested inside of it to access whatever data it contains without explicitly passing it down via props. Using core React API methods, we can create and consume a context of our own in a similar way:

Above we have the data store for an app with a simple counter widget that keeps track of a number as it’s stepped up and down. If you’re familiar with Redux, the reducer function should look familiar to you. It’s a pure function that takes two objects as arguments: one represents our application’s present state, and the other represents some action we’re performing to change that state. It knows how to do 3 different things: increment, decrement, and reset the counter. Every time it receives one of these actions (e.g. { type: 'COUNTER_INC' }), it clones the current state using the object spread operator, and then it modifies the counter property accordingly. If a change is made to the state, a brand new object is returned. If an unrecognized action is received, it returns the state unchanged.

Below the reducer function, we create our context and wrap it in a functional component that sets up the reducer hook we’ll use to interact with our application’s state. useReducer takes a function as its first argument and an initial state object as its second. It returns an array with two members: the current state and a dispatch function that we’ll use to feed subsequent actions into the reducer. After we create that reducer hook, we pass it into our context, which passes both the state and dispatcher down to all of its descendants.

Our last export, useStore, is just an easy way to give our other components access to the context we just created. We’d use it in another component like this:

In our application’s entry point, we nest our counter component inside of the StoreProvider component we created as part of our data store:

Now any component nested in StoreProvider‘s hierarchy has the option of using useStore() to gain access to our immutable, shared application state.

Since creating a shared, immutable data store is completely unnecessary in an application with only one component, let’s go ahead and add another one for giggles. This component uses the same useStore hook as the counter to display a JSON representation of the store’s state, ignoring the dispatcher:

To add this to our app, we can add it to our entry point component as a sibling of Counter:

Here’s an example of these components wired up into a working application:

Close, But No Cigar

The architecture of an app using a reducer hook and the Context API to manage state looks similar to that of a simple Redux app. Both approaches satisfy the Three Principles of Redux:

  • The state is the single source of truth for the application, represented as a single object tree in a single store.
  • The state is read-only and can only be altered by emitting an action that describes the change to be made.
  • Changes are made using pure functions that return the next state. The current state is never mutated in place.

However, my native React Hooks API implementation falls flat on its face once we increase the breadth and depth of our component tree. The approach I used in this post is good up until a certain point – it may even be preferable in very simple apps because it eliminates some of the boilerplate required by a Redux app. There’s a very good reason why Redux uses container components between the store and the UI, though; they prevent the UI from re-rendering unnecessarily when the store updates. That’s the main purpose of connect() in the react-redux package: extracting just the useful bits out of a large global state object and passing them down as props to the UI.

When you have a large, complex state tree, and each of your many components only cares about a small part of it, you don’t want to re-render every single component when a piece of inconsequential data changes in some far-off corner of your application. Redux container components prevent these re-renders via their mapStateToProps function, which transforms relevant global state values into props and passes them down to the UI.

If we were to add more UI components to our little counter application, each one that consumes our context will re-render whenever any part of the global state changes, whether it needs to or not. In an application with a lot of UI components consuming global state, this is a recipe for intractable performance problems.

Workarounds

We can change our application’s architecture in order to sidestep a great many of these unnecessary re-renders, with varying degrees of practicality.

According to a nugget of wisdom found in the React Hooks FAQ (h/t Dan Abramov on Twitter):

Note that you can still choose whether to pass the application state down as props (more explicit) or as context (more convenient for very deep updates). If you use context to pass down the state too, use two different context types — the dispatch context never changes, so components that read it don’t need to rerender unless they also need the application state.

If we only include our dispatch function in our context, then any component in our hierarchy will still be able to trigger global state changes. Since that function always remains the same, it will never cause a re-render on its own. At this point, we can create a second context that contains our global state and then use good old fashioned props to pass pieces of it down to the components that need them.

This at least allows us a measure of control over which components re-render when the store’s state changes, while still allowing any component to dispatch actions to the reducer.

Of course, now that we’re passing props manually again, we’re starting to lose much of the convenience that keeping a global state object gives us. We need to keep track of what we’re sending where, and the likelihood that we’ll forget to pass a needed prop down to a child component increases proportionally to the level of nesting. We could get back on the righteous path, but it would require us to create a higher order component that decides which pieces of state to pass down to its wrapped component… you know, kind of like connect(). At that point, we really might as well just use Redux and take advantage of all the awesome middleware and debug tooling it has available. The point of my exploration was never to reinvent Redux.

What Now?

I set out to see whether I could use useReducer in place of Redux to manage an entire application’s global state, and I found that I really couldn’t in any practical way. useReducer is far from useless, though.

Employed in a less ambitious fashion, it can help us manage the local state of components where we need to keep track of multiple, interdependent values. For example, let’s say we’re managing the state of a form where two conditions need to be true in order for the submit button to be enabled:

As the number of interdependent state properties grows, it makes more and more sense to handle them inside of a reducer function rather than keeping track of an assortment of useState hooks inside of a big if/else statement.

Expanding on this idea, instead of managing the whole application’s state with a single reducer, we could use one reducer for each component’s entry point and pass relevant pieces of state down as props to its children. This is similar to the common practice of using a stateful class component to coordinate a bunch of functional descendant components, except the top component is also a function in this case. This might not be a groundbreaking change, but it does enable our applications to benefit from future improvements to the Hooks API.

Ultimately I discovered that the Hooks API isn’t quite ready to replace Redux in the role of centrally managing the state of an entire React application, but this kind of experimentation is important to maintaining and growing my skills as a developer. Even when some cool shortcut I think of turns out to be less than viable, the process of figuring that out helps me to solidify my own understanding of best practices and drives me to dig deeper into the inner workings of software I use every day.

Postscript

After completing this piece, I happened upon a clear and concise summary of different ways to use React hooks to manage global state. Daishi Kato does a great job of exploring the pros and cons of each approach, and the two libraries he’s written around Redux and React Hooks state management look really interesting. I highly recommend heading over there if you’re interested in digging deeper.

2 Comments

Patrick Roza

Personally I don’t understand the fascination about global central state, especially when managed manually. To me it only makes it harder to manage because lifetime is not automatically connected to the lifetime of the component or tree of components that need it.

In my apps I find very few cases that really require global state, even often the state is not shared at all or with a few siblings only.
State is often just used within a single page or subtrees of it.
So personally I’m fine with useState, and sometimes reach for context when needed.

The trick I think is to decouple the consumers from the source of the data, then the data can be freely promoted up the tree depending on how much it should be shared, without having to change the components that depend on it. Basically by wrapping the useState or combinations thereof, in custom hooks.

On a side note I’m also not a fan of reducers: action dispatching by string name is not appealing to me, and with redux actions you usually add a lot indirection, increasing cognitive load to understand where things are coming from, and how they are updated.

Reply
Sean

Global state is really nice when you are dealing with the same data in various parts of your app. Rather than reloading the same data every time different components are rendered, you can just refer those components to the data that is already in your store. Make a change to the data in some branch component and the data is fresh and up to date for the other components. One store, one single source of truth. It’s not for every app, but for some, it is the best tool. Just because your apps don’t benefit from it, doesn’t mean there aren’t perfect use cases for it.

Reply

Leave a Reply

Your email address will not be published. Required fields are marked *