Scaling React State Management Part 1

Or why you might not need Redux

Andre Rabold
6 min readMay 23, 2020

React is relatively barebone — after all it’s intentionally designed as a library, not a framework. A lot of functionality will require you to install 3rd party packages, like routing and theming UI component libraries or build tools. However, state management is not necessarily one of them.

While there are good reasons why to use a state management framework such as Redux, it also can increase the complexity of your codebase, adds dependencies that you don’t control, and bloats your application size. React introduced its own state management, the Context API, in v16.3.0, and today I want to show how we can use it to manage our application state in a scalable way without additional dependencies.

Context and Reducer

I assume you already worked with Redux before and heard or even used React’s Context API before. So, I won’t cover all the basics in this post anymore. If you need a refresher, there are plenty of tutorials and overviews out there, such as How to Replace Redux with React Hooks and the Context API and LogRocket’s somewhat oddly named Use Hooks + Context, not React + Redux.

A Classic Example

Let's look at the example React gives us on how to build reducers:

If you need a refresher on how reducers work, I recommend reading through the official documentation first and then continue with this blog post.

The actual state mutation is implemented in the reducer function using a switch-case statement. This works pretty well for small applications like this example, but it doesn’t scale well to commercial products.

  1. Actions are simple JavaScript objects with no type-safety.
  2. All logic is bundled in a single reducer function rendering it huge and unmaintainable quickly.
  3. There’s no obvious way of running asynchronous code in the reducer. So, your actual business logic like fetching data still needs to reside somewhere else.

Rethinking Actions

One of the first steps people take when cleaning up their actions is to create factory functions that return a new action object. This makes dispatching actions somewhat simpler and safer:

However, this still doesn’t solve the other problems. How about moving the state mutation out of the reducer right into the action itself? So, instead of returning an action object and have the reducer evaluate that, we could return an action function that performs the mutation on its own:

Suddenly our reducer becomes much more simple! It just has to call the action function. Your logic stays with the action where it belongs.

If you’re familiar with Redux and Redux-Thunk, this should start looking quite familiar to you.

Asynchronous Actions

The next step is to turn our actions into asynchronous functions that can perform data fetches or other long-running operations.

Redux provides Redux-Thunk to run asynchronous operations. If you’re not familiar with thunks, check out the excellent summary What is a Thunk? by Dave Ceddia. We want to apply a similar mechanism here. After all, our action factories already look like thunks minus the asynchronous code.

We do this in two steps:

  1. First we define a base type for our actions using TypeScript. This will give us type-safety during coding. If you’re not using TypeScript yet, the code below will work without type annotations as well. However, I strongly recommend making the switch! You will not regret it.
    We define a ThunkAction type that represents an action function, the same type of function that should be returned by our action factories.
  2. Then we build our own reducer hook useThunkReducer that replaces useReducer and implements an asynchronous dispatch.

Let’s unwrap how this works:

  • The application state (TState) is managed using a useState hook. We can read the state via the state variable and update it by calling setState.
  • The dispatch function is essentially still simply calling our action function (the thunk) like before. However, this time we pass ourselves as the first function argument and await the result.
  • By passing the dispatch function as the first function argument we allow our thunks to continue dispatching to other actions. We can create a chain of actions with each action invoking a subsequent action and so on. This is a nice way of combining simple actions into more complex ones without duplicating logic or code.
  • To accommodate the async nature of the thunks, we don’t pass the state directly to the action function but we wrap its ref into a getState function instead. This is similar to how Redux-Thunk works. The reason we do this here is that the application state could (and often will) change between the start of the invocation of your action function and the end of it. By using getState we can always work with the most current state of the app. This is important when building the new mutated state at the end of our thunk. But more on this in the example below.

How to Thunk?

In our simple action example above, not much does change. Here’s the updated code using asynchronous thunks:

Not much to see here. Instead of using a state object we now use getState but that’s pretty much it. Everything else stays the same.

Let’s take a look at a truly asynchronous action next:

This is quite elegant. The function getAccountDetails will fetch data from our backend using Axios and then return the new state with the updated account details right away. Simple and efficient.

Using dispatch we can perform state updates while our action is still running. Again, that’s very similar to how the Redux-Thunk middleware works. In the next example, we set the state property isFetching before actually doing anything, so we can update the UI and show the user something is happening:

Neat! We can even reuse the fetchStarted action elsewhere to avoid code duplication.

The examples can get as complex as you want. It’s important to remember to use getState to always work with the latest application state, especially when updating the state at the end and after dispatching other actions. Otherwise you will encounter weird and unexpected effects with states changing back and forth seemingly randomly.

Giving It Context

So far this blog post covers how we can simplify our application actions and replace React’s useReducer hook with something that supports asynchronous actions. However, I didn’t actually cover useContext yet at all! If you worked with useContext before then things should be quite straight forward from here on:

We put the state into an AppContext provider. This will make it available in all components of our application without the need of prop-drilling. Then we simply use useContext(AppContext) and gain access to the current state as well as the dispatch function to invoke our actions.

A Word of Caution

We are using useThunkReducer successfully in commercial products right now. It works and scales very well regardless of how big and complex your application becomes. Separating the business logic into individual actions and reusing them makes it very easy to keep an overview and change things without impacting other parts of the application.

However, this is not without flaws. My biggest concern is that we always need to use getState to ensure we use the latest application state, and we must never store it in a temporary variable for more than a single asynchronous call. If you forget (and some new engineers in the team might simply not know), you get into trouble really quickly. Your state will arbitrarily flip back and forth which is very hard to debug. This is probably the most common cause of errors in state management with asynchronous actions.

Do you have any other concerns? Let me know in the comments below!

What’s Next?

In Scaling React State Management Part 2 I will look into performance pitfalls when implementing your own state management using React’s Context API. Having one global application state is very useful but will often result in unnecessary re-renders of the whole application component tree even if just a single flag somewhere deep in the state hierarchy changes. However, there’s a solution for that as well: useContextSelector. Continue to part 2

References

--

--

Andre Rabold

Venture CTO at UP.Labs, BCG Alumni, Co-Founder at Stashimi Inc., Entrepreneur, and Technologist