How to transition from useState to React Reducer
Contents
Introduction
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?'
What is a Reducer?
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
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.
Why might you want to shift logic into a Reducer?
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.
Moving our useState logic to a Reducer
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 theusers
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
- also an object with the following properties:
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 theaction
type
- in other words, the way that
- 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 theisLoading
state totrue
to renderLoading...
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 APIstate.isLoading
is set tofalse
so that theusers
array is rendered andLoading...
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
- users - matching the default
- 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 firstuseReducer
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 anactions
object to the Reducer (see above where we defined the shape of theactions
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
tostate.users
- set
isLoading
tostate.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
Conclusion
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.