Scaling React State Management Part 1
Or why you might not need Redux
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.
- Actions are simple JavaScript objects with no type-safety.
- All logic is bundled in a single reducer function rendering it huge and unmaintainable quickly.
- 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:
- 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 aThunkAction
type that represents an action function, the same type of function that should be returned by our action factories. - Then we build our own reducer hook
useThunkReducer
that replacesuseReducer
and implements an asynchronousdispatch
.
Let’s unwrap how this works:
- The application state (
TState
) is managed using auseState
hook. We can read the state via thestate
variable and update it by callingsetState
. - 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 andawait
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 agetState
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 usinggetState
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…