Hooks Revisited: useReducer

May 18, 2020

An update on state

Throughout this series, all of the code snippets and sandboxes we've created have used useState to manage our component data. However, React offers us an additional hook to use for storing data: useReducer.

While useState allows us to store and set a single value, useReducer helps us work with more complex or structured data by allowing us to store and manipulate related values alongside one another.

Anatomy of useReducer

Like useState, useReducer returns an array with two values:

  • The current state
  • A function used to update the state
const [value, setValue] = useState(null)
const [state, dispatch] = useReducer(reducer, initialState)

The useReducer hook takes up to three arguments:

  1. Reducer function -- This function describes how our state should be updated based on the action that was dispatched.

  2. Initial state -- This value defines the hook's initial state and works similiarly to how we provide the useState hook a default value when instantiating it.

  3. Initialization function -- This argument is optional and is useful for...

...calculating the initial state outside the reducer. This is also handy for resetting the state later in response to an action. ~ [React docs]

Difference from useState

To best illustrate the difference in how useReducer and useState update their state values, respectively, let's take a look at them side by side. The snippet below shows the code you'd need to use to instantiate and update a state value using both hooks:

// useState
const [name, setName] = useState("")
setName("Ryan")
console.log(name) // 'Ryan'
// useReducer
const initialState = {
name: "",
}
function reducer(state, action) {
switch (action.type) {
case "update-name":
return {
name: action.value,
}
}
}
const [state, dispatch] = useReducer(reducer, initialState)
dispatch({ type: "update-name", value: "Ryan" })
console.log(state.name) // 'Ryan'

The first difference here is that while useState is storing a string, useReducer's initial value is an object. In this case, it has a single key (i.e. name), however, we can always add more keys to the state as we build out our UI.

Secondly, while useState's setter function updates its value directly, useReducer dispatches an action. The reducer function then determines what type of action was fired and, subsequently, how to update its state.

Note: If you haven't used it in the past, this is pretty much how Redux works.

In practice

In the sandbox below, I've built a form for scheduling an appointment. Though there are multiple inputs with different types, all of the values are related to one another as they are in the same <form>.

Instead of storing each input value in its own useState hook, we can store and manage all of the values in our form using a single useReducer. In this case, its state is an object with many keys, each representing a different value we want to store. Personally, this reminds me a bit of this.state in class components before we had hooks.

In App.js, we've defined our initial state like this:

const blankForm = {
name: "",
email: "",
date: "",
time: "",
feeling: "3",
}
const [formState, dispatch] = useReducer(reducer, blankForm)

Each of the fields in the blankForm object represents and stores the value for an associated input in our form. Since the initial state of email is an empty string, the e-mail input will be blank on render as it reads its value from useReducer's state:

<input
className="Form__input"
name="email"
type="email"
value={formState.email}
/>

To make this work, we've also set our inputs' onChange handlers to dispatch specific actions in order to update the state. Here's what our e-mail input now looks like:

<input
className="Form__input"
name="email"
type="email"
value={formState.email}
onChange={event => {
dispatch({ type: "setEmail", value: event.target.value })
}}
/>

In the snippet above, we're specifically dispatching the setEmail action. Inside of our reducer function, the switch statement looks for the case that matches the action.type and executes its logic to update state:

function reducer(state, action) {
switch (action.type) {
case "setName":
return {
...state,
name: action.value,
}
case "setEmail":
return {
...state,
email: action.value,
}
case "setDate":
return {
...state,
date: action.value,
}
case "setTime":
return {
...state,
time: action.value,
}
case "setFeeling":
return {
...state,
feeling: action.value,
}
case "reset":
return blankForm
}
}

For example, when setEmail is called the reducer returns a new object that contains all of the current state information, except it also updates the email field.

return {
...state,
email: action.value,
}

Finally, since our useReducer hook's state has now been updated, the component will re-render and the <input />s all display their updated value from formState.

Notes about performance

As my friend Josef Aidt pointed out while reviewing an early draft of this article, our use case for useReducer in the sandbox above has certain performance implications. Since each input's onChange function fires each time an input's value changes, we are actually causing our component to re-render on each key press. This is alright for demonstration purposes, but is something to be aware of when building production apps.

Two ways we could avoid this are:

  • Adding a debounce to each input, so that we do not trigger a state update on each keypress.
  • Storing our input values in refs instead of useReducer as changing the value of a ref does not cause our component to re-render (see my useRef article for more on this).

Now, go forth and be performant!