understanding the useReducer Hook

The useReducer hook was one React hook that every time I had to use it, I wasn't sure of exactly what was going on and if I needed it.

const [state, dispatch] = useReducer(reducer, initialState);
// you can also pass an optional init function as the third argument, for you to set the initial state lazily ( delaying setting state until the first time it is needed), can come in handy when you want to extract the logic for calculating the initial state, outside the reducer

usually after this line of code, you get to see something like this:

dispatch({ type: ACTION_TYPE, payload: data })

If you also used to wonder how the dispatch function managed to update the state, this article will try to explore how the useReducer hook works internally, why we need it, and you will also see how the dispatch function "magically" updates the state.

The React documentation points to useReduer as an alternative to useState and uses useState in its implementation, so you can probably think of useReducer, as an "inbuilt custom hook" since it relies on another React hook. If useReducer implementation was built off useState, then why do we even need useReducer, why don't we just use useState? Before we consider this, let's take a look at a simplified version of useReducer's implementation, from the React docs

function useReducer(reducer, initialState) {
  const [state, setState] = useState(initialState);

  function dispatch(action) {
    const nextState = reducer(state, action);
    setState(nextState);
  }

  return [state, dispatch];
}

from the useReducer function above, you can see how how the useReducer hook works.

  • It takes a reducer ( a pure function that takes the previous state and an action and returns the next state), and the initial state as arguments. The reducer function looks similar to this
function appReducer(state, action) {
  switch (action.type) {
    case 'add':
      return [ /* returns a new state together with updated value */ ]
    // ... other actions ...
    default:
      return state;
  }
}
  • It then uses useState to manage the state of the component.

  • The state of the component will be updated by running a function called dispatch (which takes an action argument).

  • Inside the dispatch function, the state is updated by calling setState with nextState as its argument, where nextState is the value that is returned by calling the reducer function with the current state and the action passed to the dispatch.

  • finally, it returns an array containing the latest state and a dispatch method, which can be used to trigger the state update.

The question is, why would we ever want to use this hook since it ends up still having to use useState? why don't we just always use useState, why use useReducer, if it relies on useState?

Here is a statement from React docs

useReducer is usually preferable to useState when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one.

You can still use useState, for any use case where useReducer comes in handy, but useReducer provides you an abstraction over useState. let's consider the two use cases of useReducer as mentioned in the docs and how the useReducer abstraction handles the complex state logic better.

1. complex state logic that involves multiple sub-values: let us consider a scenario, where you building a component that needs to get a list of books from an API, every time the API is called you will need an updated state in your application indicating the status of the request (whether the data is still being fetched or not), error (whether there was an error not), and then the list of books returned. using hooks, we have three options to handle this scenario

  1. use a separate useState hook for each of these state conditions/values
  2. put them all in one useState as an object
  3. use a useReducer to manage these states together

None of these options is wrong and can be used in this scenario, though ruling out option one seems like a good move in this case, since the states changes together, it's logical enough to keep them all in one useState rather than having three useState hooks. Also, using useReducer, in this case, seems like quickly jumping on an abstraction, without an actual need for it.

However, if we had different components on a page that relies on the books state value, can change it and update the books list (more like a full CRUD operation, happening), handling all of this by having to call our setState with a callback function trying to access the previous state and updating state every time, begins to seem a bit repetitive ad hence makes an abstraction okay, therefore useReducer can come in handy. Since several components are relying on this state, we could also handle this issue using React Context, manage the state using useReducer, then wrap all the components that need the books state ( i.e the loading status, error, and all books in the list) in Book Context Provider. Here's an example

import React, { useReducer } from "react";
import axios from "axios"
import BookContext from "./bookContext"
import bookReducer from "./bookReducer"
import { ADD_BOOK, CLEAR_STATUS, DELETE_BOOK, BOOK_ERROR, CLEAR_BOOK, GET_BOOKS } from "../types"

export default function BookState(props) {
    const initialState = {
        books: null,
        status: null,
        error: null
    }
    const config = {
        headers : {
            "Content-Type" : "application/json"
        }
    }
const [state, dispatch] = useReducer(bookReducer, initialState)

const addBook = async formData => {
    try {
        const response  = await axios.post("/api/books/", formData, config)
        dispatch({ type: ADD_BOOK, payload : response.data.message
        })
    } catch (error) {
        dispatch({ type : BOOK_ERROR, payload : error.response.data.message
        })
    }
}
const getBooks = async () => {
    try {
        const response = await axios.get("/api/books", config)
        dispatch({
            type : GET_BOOKS,
            payload :response.data.books
        })
    } catch(error) {
        dispatch({
            type : BOOK_ERROR,
            payload : error.response.data.message
        })
    }
}
const deleteBook = async id => {
    try {
        const response = await axios.delete(`/api/books/${id}`, config)
        dispatch({
            type : DELETE_BOOK,
            payload : response.data.status
        })
    } catch (error) {
        dispatch({
            type : BOOK_ERROR,
            payload : error.response.data.message
        })
    }
}

    return (
        <BookContext.Provider value={{
            error: state.error,
            books: state.books,
            status: state.status,
            addBook,
            getBook,
            deleteBook,
        }}>
            {props.children}
        </BookContext.Provider>
    )
}

So rather than calling setState (what we would have called our state update function if we were using useState) every time with a callback function that handles the logic of updating our state value, we abstract all of this functionality into useReducer hook and all we have to do is dispatch an action to trigger an update to our state. our reducer function looks like this

import { ADD_BOOK, BOOK_ERROR, CLEAR_BOOK, CLEAR_STATUS, DELETE_BOOK, GET_BOOKS } from "../types";
export default  (state, action) => {
    switch (action.type) {
        case ADD_BOOK:
        case GET_BOOKS:
            return ({...state, books : action.payload})
        case DELETE_BOOK:
            return ({...state, status : action.payload})
        case BOOK_ERROR:
            return ({...state, error : action.payload}) 
        default:
            return state;
    }
}

the bookContext file is just where our context is created

import { createContext } from "react";
const bookContext = createContext();
export default bookContext

and our types values are just "string values" e.g

const ADD_BOOK = 'ADD_BOOK';

Hopefully, this use case was able to show you when to rely on useReducer, rather on useState for complex state logic with sub-values.

2. next state depends on the previous one: This use case was well covered in the React docs. Here is an example pulled from React docs

function init(initialCount) {  return {count: initialCount};}
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    case 'reset':      
      return init(action.payload);   
 default:
      throw new Error();
  }
}

function Counter({initialCount}) {
  const [state, dispatch] = useReducer(reducer, initialCount, init);  
return (
    <>
      Count: {state.count}
      <button
        onClick={() => dispatch({type: 'reset', payload: initialCount})}>        Reset
      </button>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

notice that the next state count value depends on adding or removing one to/from the previous state value. You would also notice that there is a third argument used here (an init function). This has nothing to do with the next state being dependent on the previous one but was used for lazy initialization (a performance optimization where you defer (potentially expensive) object creation until just before you actually need it). It comes in handy when you want to extract the logic for calculating the initial state outside the reducer or when you want to reset the state later in response to an action. The initial state is set by React calling

init(initialCount)

If we didn't want to reset state in response to an action, we could have just used the simpler way of initializing state in useReducer

const initialState = {count: 0};

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

Here we initialize our initial state to 0 and the next state value is dependent on the previous one.

Conclusion: Hopefully this article has been able to help you understand how the useReducer hook works and when it comes in handy. when you have

- complex state logic that involves multiple sub-values

- next state depends on the previous one

then you most likely be needing the abstraction useReducer affords us.