How to transition from useState to React Reducer

React
useReducer
state

Contents

This post follows on from a previous post about using React Context. We are going to look at the steps you need to go through to move from using useState to using React Reducer to manage state in the Users component.

Before we get into that, you might be wondering 'What even is a Reducer?' and 'Why might I want to use one?'

A 'reduction operation' is a fundamental concept from computer science. It refers to an operation through which a collection of elements is converted to a single value. You can see it in action in JavaScript's Array.prototype.reduce() higher order function introduced in ES6. You can use that function to 'reduce' an array to a single value.

Array.reduce() is an iterator function meaning it steps through an array one element at a time. Since the aim is to output a single value, the function needs to keep track of the state of that single value throughout the process so that, on each iteration through the array, it can update that state based on the content of the element that is the subject of the current iteration.

The Array.reduce iterator function takes, as its first parameter, a Reducer function which itself takes two parameters:

  • the current state of the value to be output, and
  • a variable representing the array element being processed during the current iteration

An example might help to clarify. Imagine you have an array of integers and you want to know the sum of those integers. You can make use of the reduce() function like this:

const numbers = [1,2,3,4,5] const total = numbers.reduce((acc, number) => acc + number) // total now holds the value 15

React Reducers operate in a similar way. They also take 2 parameters:

  • current state, and
  • an action to be performed on that state

The output from a React Reducer is the new state.

If your application state has a small number of variables, each of which can be be altered in just one or two ways, you can manage application state perfectly well using just the useState hook. As your application grows, one or both of the following things might happen:

  • the number of state variables increases
  • the number of ways in which each state variable can be altered increases

The implications of that increased complexity are that:

  • each separate state variable needs its own useState Hook
  • separate handler functions are needed to implement state changes

As a result, state logic tends to become scattered across a number of different state variables that are acted upon by a number of different user interaction handlers.

When you find yourself in this position it might be time to consider using a Reducer to consolidate your state logic into a function held separately from your component.

Immutability

Another benefit of Reducers is that they are pure functions: for a given input they will always return the same output and they have no side effects. This is a valuable feature because it helps to maintain the immutability of our application state. What I mean by that is, the new state returned by a Reducer function is the only version. We don't mutate arrays or objects as side effects: we always return a completely new version of the state.

In practice that means that, for example, we don't push a new value to an array. Instead we:

  • spread the old array,
  • add a new value to it and
  • return the modified array

Using pure functions to manage state also makes testing a whole lot easier because we know that a given input will always generate the same output.

Thinking back to the linked post above, we were left in a position where:

  • state is handled at a global level using React Context
  • but still relying on useState within that Context to manage state

This is what our UsersContext.jsx file looks like before we make the changes set out in this article:

// context/users/UsersContext.jsx import { createContext, useState } from "react"; const UsersContext = createContext() export const UsersProvider = ({ children }) => { const [users, setUsers] = useState([]) const [isLoading, setIsLoading] = useState(false) const fetchUsers = async () => { setIsLoading(true) const response = await fetch('https://jsonplaceholder.typicode.com/users') const data = await response.json() setUsers(data.slice(0,5)) // restrict to 5 setIsLoading(false) } return ( <UsersContext.Provider value={{ users, isLoading, fetchUsers }} > {children} </UsersContext.Provider> ) } export default UsersContext

The application state in that example is not so complex that we need to call on Reducers to simplify things - useState will work perfectly well - we are just using that small application as the start point from which to look at the process of moving from a useState to a useReducer Hook.

So, starting where we left off in that last article ...

Step 1 - Create a UsersReducer

Create context/users/UsersReducer.js and, in that file, define a function called UsersReducer that takes two parameters:

  • state
    • which is an object representing the entire users state
  • action
    • also an object with the following properties:
      • type - a string which describes the action that the user asked to be performed on the users state (eg GET_USERS)
      • payload - optional param containing data to be used in this action. If the action were ADD_USER, for example, payload would contain data for the user to be added

Your action object can actually take whatever shape you like. Convention is to give it type and payload properties.

The body of the UserReducer function should:

  • check the action passed in (this represents some action taken by the user in the UI)
  • modify users state in accordance with that action
    • in other words, the way that state needs to be modified is conditional upon the action type
  • return the modified state object

Note: in the code example below the conditional logic is enclosed in a switch statement in accordance with convention. You don't have to do it that way. A chain of if/else if statements will work just as well. You will probably find that switch statements are cleaner and easier to read where you have many different cases to handle.

Below you can see the code for a pretty basic UsersReducer function. It has definitions for two action types:

  • START_FETCH_USERS - this is dispatched at the very start of the fetchUsers process. All this does is switch the isLoading state to true to render Loading... in the UI
  • GET_USERS - this is the action that is dispatched immediately after a response has been received from the API
    • state.users is populated with the array of users coming from the API
    • state.isLoading is set to false so that the users array is rendered and Loading... is hidden.
const UsersReducer = (state, action) => { switch(action.type) { case 'START_FETCH_USERS': return { ...state, isLoading: true } case 'GET_USERS': return { ...state, users: action.payload, isLoading: false } default: return state } } export default UsersReducer

Notice that the switch statement has a default case that simply returns an unmodified state object if the action.type fails to match any of the defined cases.

In more complex switch statements it may be a good idea to wrap the separate case clauses in curly braces to create their own lexical scopes and avoid variable name clashes. Read more about lexical scope in switch statements.

Demystifying some language

Before we get into the changes required to UsersContext let's talk a bit about some of the terminology used in the world of Reducers. In the section just above this previous code block we talked about actions being dispatched. What the hell does that mean?

React gives us the useReducer Hook: a way to interract with Reducer functions like our UsersReducer. When we define a useReducer Hook we generally destructure two values from it, one of which is a method called dispatch which takes an action object as a parameter. Remember, we defined our Reducer's action parameter object a little earlier in this article: it has type and (optional) payload properties. When we pass that action to the dispatch method all we are doing is telling React to pass that action to UsersReducer.

Remember also that UsersReducer takes two parameteres: action that we have just talked about, and application state. That is the other value that is destructured from useReducer when we define the useReducer Hook. state is automatically passed to UsersReducer behind the scenes by the useReducer Hook.

Step 2 - Update UsersContext to use UsersReducer

  • Define an initialState variable holding an object with properties to match the existing state variables and values as follows:
    • users - matching the default useState value - ie. an empty array
    • isLoading - matching the default useState value - ie. false
  • Delete the two useState Hooks
  • Delete the useState import

Define a useReducer Hook

First, remember to import useReducer from React.

The useReducer function takes 2 parameters:

  • a reducer function - we have already defined UsersReducer so import that and pass it as the first useReducer parameter
  • an initial state - again we have already defined this

The useReducer function returns an array of 2 elements:

  • a value representing state
  • a dispatch function that passes an actions object to the Reducer (see above where we defined the shape of the actions object in our Reducer function)

It is customary to destructure these 2 elements in a similar way to the returned array from the useState function. Here is what my useReducer definition looks like:

const [state, dispatch] = useReducer(UsersReducer, initialState)

Note you can name these variables however you like. state and dispatch are simply convention.

Inside fetchUsers

Replace the initial setLoading call by dispatching a START_FETCH_USERS action. In our Reducer, that will set state.isLoading to true as we saw above.

Replace the later setLoading and setUsers with a dispatch function of the type GET_USERS with a payload made up of the users array retrieved from jsonplaceholder (restricted to just 5 users to keep the output small). In UsersReducer, state will be updated so that it holds the array of users and isLoading is set to false, as we saw above.

Update the <UsersContext.Provider> value prop

The variables users and isLoading no longer exist since we deleted useState. value should instead be set as follows:

  • set users to state.users
  • set isLoading to state.isLoading
  • fetchUsers remains the same: that function still exists

This is what UsersContext.jsx looks like now

import { createContext, useReducer } from "react"; import UsersReducer from "./UsersReducer"; const UsersContext = createContext() export const UsersProvider = ({ children }) => { const initialState = { users: [], isLoading: false } const [state, dispatch] = useReducer(UsersReducer, initialState) const fetchUsers = async () => { dispatch({ type: 'START_FETCH_USERS' }) const response = await fetch('https://jsonplaceholder.typicode.com/users') const data = await response.json() dispatch({ type: 'GET_USERS', payload: data.slice(0,5) // restrict to 5 users }) } return ( <UsersContext.Provider value={{ users: state.users, isLoading: state.isLoading, fetchUsers }} > {children} </UsersContext.Provider> ) } export default UsersContext

When my React journey first led me to Reducers I was completely confused. Most of that confusion stemmed from the terminology; the way things are named. Only when I started taking Reducers apart did the process make sense to me. React Hooks take care of a lot of things behind the scenes but, fundamentally, Reducers are simply functions that make changes to our component's state based on actions that reflect something that the user did in the UI.

In this article we have explored the concept of Reducers, found out what they are for and in what circumstances you might use one in your own applications. We have also looked at the changes required if your component is built using useState and you want to move to useReducer.

If, like me, you were a bit perplexed by Reducers I hope I have helped clear up a few things.