DEV Community

Cover image for useReducer – Demystifying React Hooks (Pt. 5)
Austin Ryan Taylor
Austin Ryan Taylor

Posted on • Originally published at austinrt.Medium

useReducer – Demystifying React Hooks (Pt. 5)

In this article, we will explore when and how to use React’s useReducer hook
and how it relates to the useState Hook.

If you'd like to skim this as a Medium article, it can be found here

Getting Started

  • fork and clone
  • cd client
  • npm i
  • npm start

useReducer

The intended use case for useReducer is to manage complex state logic by
taking advantage of the
Flux/Redux
pattern. And if you don't know Redux, don't worry. In my opinion, useReducer
is the perfect place to start learning Redux.

Why Bother?

Imagine we wanted to pass down all three states for our user to alter them from
inside a child component. We'd have to pass six different values: each state
and its setter. useReducer can simplify our code by reducing all of
our state logic into a single function, aptly named a reducer. Reducers allow
us to pass predetermined actions to our state updater to avoid
unintentional state updates. We can even bake in custom error handling to make
debugging easier. Think of it as hand-rolling type-checking for your state. Kind
of... But we're not going down that rabbit hole today.

Starter Code

As always, let's start with a brief overview of the codebase before we refactor.

App.js has three sections: a counter, a theme changer, and a text input, all
of which we've neatly rolled into one component to spare us file surfing. Each
section has a dedicated instance of the useState hook, which we all know and
love.

We have three pieces of state: input, count, and darkMode.

Instead of updating our state inline, we've made four breakout functions:
handleChange, incrementCount, decrementCount, and changeTheme, each of
which does exactly what its name implies. The only thing to note here is that,
with incrementCount and decrementCount, we're using an
updater function
rather than directly passing the new state value. We've done this because the
next value of the state depends on the previous value. A longhand version of the
same function would look like this:

setCount(previousState => {
  return previousState + 1;
});
Enter fullscreen mode Exit fullscreen mode

A Simple Refactor

Let's jump into our refactoring, one piece of state at a time. We'll start by
importing useReducer from 'react'.

import { useState, useReducer } from 'react';
Enter fullscreen mode Exit fullscreen mode

Next, we'll initialize our state with useReducer.

const [state, dispatch] = useReducer(reducer, initialState);
Enter fullscreen mode Exit fullscreen mode

useReducer Signature

useReducer looks a lot like useState. We're still destructuring an array
from the return and passing our initial values as arguments. However, you may
notice that, unlike useState, useReducer requires two arguments: a reducer
function and the initialState. The other difference is much more minor.

Our state is essentially the same, but rather than explicitly naming each
piece of state, the convention is to create a state object, and our
key/value pairs become our different "pieces" of state. The dispatch
function is still our state setter but requires an action object as a second
argument.

Refactoring Counter

First, we'll refactor the count state. We'll start by defining our
initialState variable. Typically this, along with the reducer function,
would be defined elsewhere and imported into our component. For simplicity's
sake, we'll define them in the same file, underneath our import statements but
not inside our component.

import {useState, useReducer} from 'react';

const initialState = {
  count: 0,
};

function App() {
  ...
Enter fullscreen mode Exit fullscreen mode

Next, we'll define our reducer function in a "separate file" (same file, but
outside our component). This reducer function requires two arguments: our
state object and an action object. The action object will have two keys:
type and payload. Later, when we dispatch an action, the type tells
useReducer what we're trying to update, while the payload provides the
updated value.

const reducer = (state, action) => {};
Enter fullscreen mode Exit fullscreen mode

Inside this reducer, we'll switch on the action's type property to determine
what to do. Of course, you could use any form of conditional logic, but a
switch statement is the convention.

const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      console.log(`Action ${action.type} not found.`);
      return state;
  }
};
Enter fullscreen mode Exit fullscreen mode

Notice that in our default, we console.log some simple feedback and return
the unaltered state. So if we pass in an action type that doesn't exist,
instead of breaking our app, we're simply made aware that we've not yet built a
case to handle this situation. This error handling can be as simple or robust
as your company's style guide requires.

As our state complexity grows, we'll need to update and refactor our
reducer. But for now, we'll move on to our functions.

Now, in incrementCount and decrementCount, instead of calling setCount, we
call dispatch, and pass it the appropriate action as an object.

const incrementCount = () => {
  dispatch({ type: 'increment' });
};

const decrementCount = () => {
  dispatch({ type: 'decrement' });
};
Enter fullscreen mode Exit fullscreen mode

And lastly, since count is now nested inside our state object, we need to
update our count reference in our JSX.

<p className='output'>{state.count}</p>
Enter fullscreen mode Exit fullscreen mode

Now we can remove our count useState instance and test our App. It will work
the same! And you'll notice our other two pieces of state still work just fine.

That's great! We've added more code that's complicated our app and reduced
readability with no tangible advantages! How cool!

Settle down. Now is where we start to see some advantages. It's time to
reduce our state into a single object.

Refactoring darkMode

We'll start this process by adding darkMode to our initialState object. In
our case, the default is true because I'm a gremlin.

const initialState = {
  count: 0,
  darkMode: 'dark'
};
Enter fullscreen mode Exit fullscreen mode

Next, we'll add a case to our reducer to handle the darkMode update.

const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'darkMode':
      return { darkMode: action.payload };
    default:
      console.log(`Action ${action.type} not found.`);
      return state;
  }
};
Enter fullscreen mode Exit fullscreen mode

As before, let's update our changeTheme function to dispatch the appropriate
action. This time, we need not only a type but also a payload. The type
will be darkMode, and the payload will be the opposite of darkMode's
current value.

const changeTheme = () => {
  dispatch({ type: 'darkMode', payload: !state.darkMode });
};
Enter fullscreen mode Exit fullscreen mode

Lastly, we'll update the reference in our JSX.

<button onClick={changeTheme}>
  {state.darkMode ? 'light mode' : 'dark mode'}
</button>
Enter fullscreen mode Exit fullscreen mode

Now you'll see that we can, again, toggle between light and dark modes! And if
we test our counter we find that...

We broke our app.

Luckily this is an easy fix. When we update our state object, it's completely
overwriting the previous state object, but we want to preserve all previous
values and only update the one being changed. So, in our reducer, all we need
to do is
spread
the current state before updating.

const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + 1 };
    case 'decrement':
      return { ...state, count: state.count - 1 };
    case 'darkMode':
      return { ...state, darkMode: action.payload };
    default:
      console.log(`Action ${action.type} not found.`);
      return state;
  }
};
Enter fullscreen mode Exit fullscreen mode

Works like a charm! We can now remove our darkMode useState instance.

Refactor input

Our last piece of state to update is our input. This will be the same process
as before.

First, we add an input to our initialState object.

const initialState = {
  count: 0,
  darkMode: true,
  input: ''
};
Enter fullscreen mode Exit fullscreen mode

Next, we add a case to our reducer to handle the input update.

const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + 1 };
    case 'decrement':
      return { ...state, count: state.count - 1 };
    case 'darkMode':
      return { ...state, darkMode: action.payload };
    case 'input':
      return { ...state, input: action.payload };
    default:
      console.log(`Action ${action.type} not found.`);
      return state;
  }
};
Enter fullscreen mode Exit fullscreen mode

Next, we update our handleInputChange function to dispatch the appropriate
action.

const handleInputChange = e => {
  dispatch({ type: 'input', payload: e.target.value });
};
Enter fullscreen mode Exit fullscreen mode

And lastly, we update our JSX to reference the proper input value.

<input
  type='text'
  value={state.input}
  onChange={handleInputChange}
/>
<p className='output'>{state.input.trim() || 'user input'}</p>
Enter fullscreen mode Exit fullscreen mode

Now we can remove our last instance of useState and test our app.

It works! Congrats! You've just built your first React App that updates state
without useState!

Refactoring Our action Object

Now that our app is fully functional, there are a few more things we can do to
optimize our code. We'll start by refactoring our action object. Rather than
evaluating our state updates based on passing around strings, we can make our
action object a const that we then reference in our reducer. Not only does
this help us avoid typos, but if we need to update our action object, we only
need to do so in one place, making our app much more maintainable.

Again, in a "separate" file, we'll define a proper const using all caps.

const ACTION = {
  INCREMENT: 'increment',
  DECREMENT: 'decrement',
  DARK_MODE: 'darkMode',
  INPUT: 'input'
};
Enter fullscreen mode Exit fullscreen mode

Now, in our reducer, instead of comparing action.type to a string, we can
compare it to ACTION.

const reducer = (state, action) => {
  switch (action.type) {
    case ACTION.INCREMENT:
      return { ...state, count: state.count + 1 };
    case ACTION.DECREMENT:
      return { ...state, count: state.count - 1 };
    case ACTION.DARK_MODE:
      return { ...state, darkMode: action.payload };
    case ACTION.INPUT:
      return { ...state, input: action.payload };
    default:
      console.log(`Action ${action.type} not found.`);
      return state;
  }
};
Enter fullscreen mode Exit fullscreen mode

And in our dispatch calls, we'll remove the strings and reference the ACTION
const as our type.

const incrementCount = () => {
  dispatch({ type: ACTION.INCREMENT });
};

const decrementCount = () => {
  dispatch({ type: ACTION.DECREMENT });
};

const changeTheme = e => {
  dispatch({ type: ACTION.DARK_MODE, payload: !state.darkMode });
};

const handleInputChange = e => {
  dispatch({ type: ACTION.INPUT, payload: e.target.value });
};
Enter fullscreen mode Exit fullscreen mode

You'll notice that this approach not only avoids typos and makes your code more
maintainable, but now your IDE offers you autocomplete suggestions.

The Tradeoffs

As with everything in development, there are tradeoffs. In this case, we seem to
have simplified our state by complicating it. Obviously, this is overkill for a
counter app with a random input field and a light/dark mode toggle. But as our
apps grow, we'll find that this pattern, while heavy on the setup, simplifies
overall. For example, take a look at this state tree from Airbnb. Or, head to
airbnb.com and see for yourself using the Redux DevTools.

airbnb state trace tree

Now, imagine a different useState hook for each of those values. And imagine
if you needed to pass these values around your app. There would be so much prop
drilling that your code would be entirely unreadable.

Conclusions

So, when should you use useState, and when should you use useReducer?

That, my friend, is up to you (or your future company's style guide and coding
principles). If you're able to accurately predict the complexity of your app,
the extra work to set up useReducer from the start might be worth it. If you
know you won't be scaling up, or you're okay with the future tech debt, maybe
you stick with useState. The most likely scenario is that you'll use a mixture
of both.

Either way, you now have the tools to make an informed decision. And that's
arguably more important than what you decide. And now that you're armed with
this knowledge, add a central store or some slices for your state, and you're
using Redux!

I’m always looking for new friends and colleagues. If you found this article helpful and would like to connect, you can find me at any of my homes on the web.
GitHub | Twitter | LinkedIn | Website

Resources

Top comments (0)