Demystifying React Hooks — useReducer

austin
8 min readFeb 2, 2023

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 clone this down and run it locally, you can find the repo 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.

So, 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:

A Simple Refactor

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

Next, we’ll initialize our state with useReducer.

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 count

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.

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.

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.

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.

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

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.

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

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.

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

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.

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

Refactoring 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.

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

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

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

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.

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

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

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.

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

--

--