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 section
s: 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.