useReducer, don't useState

Published See discussion on Bluesky

This blog post assumes you have a decent initial understanding of React Hooks. I highly suggest starting with the ReactJS Docs on them first.

As developers start adopting React Hooks within their applications, many will be tempted to start with useState as their state management preferences for local component state. However, I would like to try an convince you that useReducer is a better way to manage local state.

Lets start of with defining "better" in my premise from above, the definition I will use for this article will be that its:

  • Easier to manage larger state shapes
  • Easier to reason about by other developers
  • Easier to test

So lets break down each of these three points.

Easier to manage larger state shapes

As with most of this blog post, this is mostly my opinion, however because useState no longer shallowly merges state updates like it does within classes, using a reducer function gives you the developer more control over the state merging.

As an example of this expresivity that a reducer gives us, we can useReducer to implement an undo/redo state management solution[1]:

1function init(initialState) {
2 return {
3 past: [],
4 present: initialState,
5 future: [],
6 };
7}
8function reducer(state, action) {
9 const { past, future, present } = state;
10 switch (action.type) {
11 case 'UNDO':
12 const previous = past[past.length - 1];
13 const newPast = past.slice(0, past.length - 1);
14 return {
15 past: newPast,
16 present: previous,
17 future: [present, ...future],
18 };
19 case 'REDO':
20 const next = future[0];
21 const newFuture = future.slice(1);
22 return {
23 past: [...past, present],
24 present: next,
25 future: newFuture,
26 };
27 default:
28 return state;
29 }
30}
1function init(initialState) {
2 return {
3 past: [],
4 present: initialState,
5 future: [],
6 };
7}
8function reducer(state, action) {
9 const { past, future, present } = state;
10 switch (action.type) {
11 case 'UNDO':
12 const previous = past[past.length - 1];
13 const newPast = past.slice(0, past.length - 1);
14 return {
15 past: newPast,
16 present: previous,
17 future: [present, ...future],
18 };
19 case 'REDO':
20 const next = future[0];
21 const newFuture = future.slice(1);
22 return {
23 past: [...past, present],
24 present: next,
25 future: newFuture,
26 };
27 default:
28 return state;
29 }
30}

Using this reducer we can keep track of a stack of states that happen in the future and in the past, allowing the user to undo and redo their actions.

This would be fairly difficult to coordinate using useState, thats not to say that its impossible but the benefit of useReducer is the explicitness of this pattern. Which leads into the second point.

Easier to reason about by other developers

Probably a topic for another blog post, but there is no such thing as a tech-only problem in web development. Frequently, you will be building features with other developers, that have a wide variety of experience different from your own.

This is mostly a more generic topic that permeates through other topics than just React Hooks, but the general take-away with the benefit of useReducer over useState is it builds on the concepts that many developers learned working with Redux within React applications[2]. The concept of dispatching an action and having your reducer handle the state updating logic will allow these developers to more easily grasp this method of state management over useState.

One thing to note in this reasoning, is that even if you are building a project all by yourself, you can consider the future you that comes back to work on the project as another engineer.

Easier to test

If there is one general topic that I have seen the most in discussions on the original Hooks RFC, or the React repo since the 16.8 release, or even on Twitter its how developers are really confused with how to test Hooks. I think it might take developers a while to learn how best to test their hooks and components using hooks, however the beauty of the useReducer hook, is that all your business logic for updating the state can exist in a separate function that is exported separately from your component.

This separation of the state updating logic and rendering logic, allows you to author tests for the state updating separate from the component rendering tests. Using our reducer from the above snippet, we can easily test the logic for undoing and redoing actions simply by calling our reducer function from with the test using some mocked state, and an action. We don't even need to import or use react at all within our test!

1test('it supports undoing the state', () => {
2 const state = {
3 past: [{ count: 0 }],
4 present: { count: 1 },
5 future: [],
6 };
7 const newState = reducer(state, { type: 'UNDO' });
8 expect(newState.present.count).toBe(0);
9});
1test('it supports undoing the state', () => {
2 const state = {
3 past: [{ count: 0 }],
4 present: { count: 1 },
5 future: [],
6 };
7 const newState = reducer(state, { type: 'UNDO' });
8 expect(newState.present.count).toBe(0);
9});

In Summary

I don't expect to persuade most developers to only ever useReducer over useState, nor do I personally expect to only ever use the useReducer hook over useState, they both have benefits and fallbacks that depend entirely upon their use. However, I do think that useReducer when used as a replacement for complex state management happening within an old class based component or replacing a react-redux setup can be more maintainable.


Footnotes:

[1] - Implementation taken from ReduxDocs
[2] - I do think there will still be a shift from developers used toRedux getting into Hooks however, as middleware solutions like redux-thunkwill need to be re-implemented using useEffect.