Refactor Your Component's State with useReducer

Feb 12, 2020

7 min read

I'll start with a disclaimer: I am not the biggest fan of Redux.

I think Redux has some great ideas, but it tends to overcomplicate things and create more problems than it solves.

It wasn't until I started using React's useReducer hook that I started to appreciate what a reducer does.

As I started using it more, I even started to realize that I hadn't fully grasped what a "reducer" actually was.

A reducer is basically a function that takes in a series of actions and an initialState, and then "reduces" them to get the current state of the application.

If this sounds familiar, it's because it's the same as the .reduce method for arrays:

1const actions = [{
2 type: 'SIGN_IN',
3 payload: { name: 'Chidi' }
4}, {
5 type: 'SIGN_OUT'
6}]
7
8const initialState = {
9 user: null,
10}
11
12const currentState = actions.reduce((state, action) => {
13// to find the current state of our app
14// we take our initialState and apply all the actions on top of it
15 if (action.type === 'SIGN_IN') {
16 state = { ...state, user: { name: action.payload } }
17 }
18
19 if (action.type === 'SIGN_OUT') {
20 state = { ...state, user: null }
21 }
22
23 return state;
24// this is how we define our initial state in `.reduce`
25}, initialState);
26
27// currentState would equal { user: null }

Here, currentState would return { user: null }, because even though we signed in, the last action we took was signing out.

If you've used Redux or useReducer before, this will look similar even though there's no actual "reducer" in this code.

The reason this is helpful in React is because we frequently have lots of pieces of state we need to track, and they'll often change in groups.

So instead of using 4 or 5 useState calls, we can use a reducer to create actions that describe our different state changes.

Let's start with this example of a login form:

This is a fairly standard component pattern for something like a login form. It includes:

  • Tracking input values and storing them in state
  • A loading state boolean to disable parts of the UI
  • Error handling and error message feedback

In its current form it works fine, but if we added one or two more fields (maybe something like a "Stay Logged In" checkbox), it could start to become hard to manage.

Instead, we could try to rethink our component's state changes in terms of "actions".

When we make a request, we could call the action something like START_SIGN_IN, and it would change our isLoading field to true.

When we get data back, we could make an action called SIGN_IN_SUCCESS, which would set the isLoading field to false and set our data with our request response.

Let's start by converting these "actions" into a reducer:

1// a reducer takes the current state and the next action as params
2function reducer(state, action) {
3 switch (action.type) {
4 // we'll use this to handle our input field changes
5 case 'HANDLE_CHANGE':
6 // this syntax allows us to dynamically declare the key on an object
7 // so for our "email" input, this would be { email: "chidi@gmail.com" }
8 return { ...state, [action.name]: action.payload };
9 // this is how we start our loading step
10 case 'START_SIGN_IN':
11 return { ...state, isLoading: true };
12 case 'SIGN_IN_SUCCESS':
13 return { ...state, isLoading: false, data: action.payload };
14 case 'SET_ERROR_MESSAGE':
15 return { ...state, isLoading: false, errorMessage: action.payload };
16 // we use this default to make sure any typos don't fail silently
17 default:
18 throw new Error(`Action ${action.type} not found`);
19 }
20}

Now, we can take this reducer and pass it to the React useReducer hook like this:

1// for our initial state, we'll use all the initial states above in our `useState()` calls
2const initialState = {
3 email: '',
4 password: '',
5 isLoading: false,
6 data: null,
7 errorMessage: null,
8}
9
10const LoginForm = () => {
11 // we'll use our `reducer` from above
12 const [state, dispatch] = useReducer(reducer, initialState);
13 // ...rest of the component
14}

Now we have a state object and a dispatch function that we can use to call our actions. So if we wanted to handle a successful sign in, we would call:

1dispatch({ type: 'SIGN_IN_SUCCESS', payload: data})

This is telling our reducer that the action we want to use is called SIGN_IN_SUCCESS, and the payload is any additional data we need to pass to our action. In this case, it's the response we're getting from the API.

Now we can change all of our setState calls to dispatch calls, and you'll see how it becomes easier to understand what's happening at each step in your app.

For example, in the handleSubmit function, you can read the actions like instructions. First you "start sign in", and if it's successful you dispatch SIGN_IN_SUCCESS. If something goes wrong, you set an error message.

This is not only easier for you to read, but it's much easier for your coworkers to read as well. It also forces you to think about your state not as an object that's being changed, but as a "state machine" that's changed by actions.

Here's what your final result might look like:

1const LoginForm = () => {
2 const [state, dispatch] = useReducer(reducer, initialState);
3
4 const { email, password, isLoading, errorMessage } = state;
5
6 async function handleSubmit(e) {
7 e.preventDefault();
8 // before we send the request, change loading state
9 dispatch({ type: 'START_SIGN_IN' });
10
11 try {
12 // send data to your API
13 const response = await api.post('/login', { email, password })
14
15 // if it's successful, toggle the loading boolean and set the data in state
16 dispatch({ type: 'SIGN_IN_SUCCESS', payload: response.data });
17 } catch (error) {
18 // if something goes wrong, they shouldn't get stuck in the loading state
19 dispatch({ type: 'SET_ERROR', payload: error.message });
20 }
21 }
22
23 return (
24 <form onSubmit={handleSubmit}>
25 <fieldset disabled={isLoading}>
26 <label>
27 Email
28 <input
29 value={email}
30 onChange={e =>
31 dispatch({
32 type: 'HANDLE_CHANGE',
33 name: 'email',
34 payload: e.target.value,
35 })
36 }
37 />
38 </label>
39 <label>
40 Password
41 <input
42 type="password"
43 value={password}
44 onChange={e =>
45 dispatch({
46 type: 'HANDLE_CHANGE',
47 name: 'password',
48 payload: e.target.value,
49 })
50 }
51 >
52 </label>
53 {errorMessage !== null && <div>{errorMessage}</div>}
54 <button type="submit">Sign In</button>
55 </fieldset>
56 </form>
57 );
58};

If you want to push this even further, you could even turn this reducer into a custom hook called something like useRequest and use it throughout your app.