Ejiro Asiuwhu Frontend engineer with rock-solid experience in building complex interactive applications with JavaScript, TypeScript, Vue.js, NuxtJS, React, Next.js, and other tools in the JS ecosystem. I regularly author meaningful technical content ✍🏽.

React useReducer Hook ultimate guide

12 min read 3510

React Usereducer Ultimate Guide

Editor’s note: This article was last updated on 28 March 2022 to include references to newer tools and frameworks.

useReducer is one of the additional Hooks that shipped with React v16.8. An alternative to the useState Hook, useReducer helps you manage complex state logic in React applications. When combined with other Hooks like useContext, useReducer can be a good alternative to Redux, Recoil or MobX. In certain cases, it is an outright better option.

While Redux, Recoil, and MobX are usually the best options for managing global state in large React applications, more often than necessary, many React developers jump into these third-party state management libraries when they could have effectively handled their state with Hooks.

When you consider the complexity of getting started with a third-party library like Redux, which is made much easier with Redux Toolkit, and the excessive amount of boilerplate code needed, managing state with React Hooks and the Context API becomes quite an appealing option. There’s no need to install an external package or add a bunch of files and folders to manage global state in our application.

But, the golden rule still remains. Component state for component state, Redux for application state. In this tutorial, we’ll explore the useReducer Hook in depth, reviewing the scenarios in which you should and shouldn’t use it. Let’s get started!

Table of contents

How does the useReducer Hook work?

The useReducer Hook is used to store and update states, just like the useState Hook. It accepts a reducer function as its first parameter and the initial state as the second.

useReducer returns an array that holds the current state value and a dispatch function to which you can pass an action and later invoke it. While this is similar to the pattern Redux uses, there are a few differences.

For example, the useReducer function is tightly coupled to a specific reducer. We dispatch action objects to that reducer only, whereas in Redux, the dispatch function sends the action object to the store. At the time of dispatch, the components don’t need to know which reducer will process the action.

For those who may be unfamiliar with Redux, we’ll explore this concept a bit further. There are three main building blocks in Redux:

  • A store: An immutable object that holds the application’s state data
  • A reducer: A function that returns some state data, triggered by an action type
  • An action: An object that tells the reducer how to change the state. It must contain a type property and can contain an optional payload property

Let’s see how these building blocks compare to managing state with the useReducer Hook. Below is an example of a store in Redux:

We made a custom demo for .
No really. Click here to check it out.

import { createStore } from 'redux'

const store = createStore(reducer, [preloadedState], [enhancer])

In the code below, we initialize state with the useReducer Hook:

const initialState = { count: 0 }

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

The reducer function in Redux will accept the previous app state and the action being dispatched, calculate the next state, and return the new object. Reducers in Redux follow the syntax below:

(state = initialState, action) => newState

Let’s consider the following example:

// notice that the state = initialState and returns a new state

const reducer = (state = initialState, action) => {
   switch (action.type) {
      case 'ITEMS_REQUEST':
         return Object.assign({}, state, {
            isLoading: action.payload.isLoading
         })
      case ‘ITEMS_REQUEST_SUCCESS':
         return Object.assign({}, state, {
            items: state.items.concat(action.items),
            isLoading: action.isLoading
         })
      default:
         return state;
   }
}
export default reducer;

React doesn’t use the (state = initialState, action) => newState Redux pattern, so the reducer function works a bit differently. The code below shows how you’d create reducers with React:

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();
  }
}  

Below is an example of an action that can be carried out in Redux:

{ type: ITEMS_REQUEST_SUCCESS, payload: { isLoading: false } }

// action creators
export function itemsRequestSuccess(bool) {
   return {
      type: ITEMS_REQUEST_SUCCESS,
      payload: {
      isLoading: bool,
    }
   }
}

// dispatching an action with Redux
dispatch(itemsRequestSuccess(false))    // to invoke a dispatch function, you need to pass action as an argument to the dispatch function

Actions in useReducer work in a similar way:

// not the complete code
switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    default:
      throw new Error();
  }

// dispatching an action with useReducer
 <button onClick={() => dispatch({type: 'increment'})}>Increment</button>

If the action type in the code above is increment, our state object is increased by 1.

The reducer function

The JavaScript reduce() method executes a reducer function on each element of the array and returns a single value. The reduce() method accepts a reducer function, which itself can accept up to four arguments. The code snippet below illustrates how a reducer works:

const reducer = (accumulator, currentValue) => accumulator + currentValue;
[2, 4, 6, 8].reduce(reducer)
// expected output: 20

In React, useReducer essentially accepts a reducer function that returns a single value:

  const [count, dispatch] = useReducer(reducer, initialState);

The reducer function itself accepts two parameters and returns one value. The first parameter is the current state, and the second is the action. The state is the data we are manipulating. The reducer function receives an action, which is executed by a dispatch function:

function reducer(state, action) { }

dispatch({ type: 'increment' })

The action is like an instruction you pass to the reducer function. Based on the specified action, the reducer function executes the necessary state update. If you’ve used a state management library like Redux before, then you must have come across this pattern of state management.

Specifying the initial state

The initial state is the second argument passed to the useReducer Hook, which represents the default state:

const initialState = { count: 1 }

// wherever our useReducer is located
const [state, dispatch] = useReducer(reducer, initialState, initFunc)

If you don’t pass a third argument to useReducer, it will take the second argument as the initial state. The third argument, which is the init function, is optional. This pattern also follows one of the golden rules of Redux state management; the state should be updated by emitting actions. Never write directly to the state.

However, it’s worth noting that the Redux state = initialState convention doesn’t work the same way with useReducer because the initial value sometimes depends on props.

Creating the initial state lazily

In programming, lazy initialization is the tactic of delaying the creation of an object, the calculation of a value, or some other expensive process until the first time it is needed.

As mentioned above, useReducer can accept a third parameter, which is an optional init function for creating the initial state lazily. It lets you extract logic for calculating the initial state outside of the reducer function, as seen below:

const initFunc = (initialCount) => {
    if (initialCount !== 0) {
        initialCount=+0
    }
  return {count: initialCount};
}

// wherever our useReducer is located
const [state, dispatch] = useReducer(reducer, initialCount, initFunc);

If the value is not 0 already, the initFunc above will reset the initialCount to 0 on page mount, then return the state object. Notice that this initFunc is a function, not just an array or object.

The dispatch method

The dispatch function accepts an object that represents the type of action we want to execute when it is called. Basically, it sends the type of action to the reducer function to perform its job, which, of course, is updating the state.

The action to be executed is specified in our reducer function, which in turn, is passed to the useReducer. The reducer function will then return the updated state.

The actions that will be dispatched by our components should always be represented as one object with the type and payload key, where type stands as the identifier of the dispatched action and payload is the piece of information that this action will add to the state.

The dispatch is the second value returned from the useReducer Hook and can be used in our JSX to update the state:

// creating our reducer function
function reducer(state, action) {
  switch (action.type) {
   // ...
      case 'reset':
          return { count: action.payload };
    default:
      throw new Error();
  }
}

// wherever our useReducer is located
const [state, dispatch] = useReducer(reducer, initialCount, initFunc);

// Updating the state with the dispatch functon on button click
<button onClick={() => dispatch({type: 'reset', payload: initialCount})}> Reset </button>

Notice how our reducer function uses the payload that is passed from the dispatch function. It sets our state object to the payload, i.e., whatever the initialCount is.

Note that we can pass the dispatch function to other components through props, which alone is what allows us to replace Redux with useReducer.

Let’s say we have a component that we want to pass as props to our dispatch function. We can easily do that from the parent component:

<Increment count={state.count} handleIncrement={() => dispatch({type: 'increment'})}/>

Now, in the child component, we receive the props, which, when emitted, will trigger the dispatch function and update the state:

<button onClick={handleIncrement}>Increment</button>

Bailing out of a dispatch

If the useReducer Hook returns the same value as the current state, React will bail out without rendering the children or firing effects because it uses the Object.is comparison algorithm.

Building a simple counter app with the useReducer Hook

Now, let’s put our knowledge to use by building a simple counter app with the useReducer Hook:

import React, { useReducer } from 'react';

const initialState = { count: 0 }
 // The reducer function
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 }
    case 'decrement':
      return { count: state.count - 1 }
    case 'reset':
      return {count: state.count = 0}
    default:
     return { count: state.count  }
  }
}

const Counter = () => {
  const [state, dispatch] = useReducer(reducer, initialState)

  return (
    <div>
      Count: {state.count}
       <br />
       <br/>
       <button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
       <button onClick={() => dispatch({ type: 'decrement'})}>Decrement</button>
       <button onClick={() => dispatch({ type: 'reset'})}>Reset</button>
    </div>
  );
};

export default Counter;

First, we initialize the state with 0, then we create a reducer function that accepts the current state of our count as an argument and an action. The state is updated by the reducer based on the action type. increment, decrement, and reset are all action types that, when dispatched, update the state of our app accordingly.

To increment the state count const initialState = { count: 0 }, we simply set count to state.count + 1 when the increment action type is dispatched.

useState vs. useReducer

useState is a basic Hook for managing simple state transformation, and useReducer is an additional Hook for managing more complex state logic. However, it’s worth noting that useState uses useReducer internally, implying that you could use useReducer for everything you can do with useState.

However, there are some major differences between these two Hooks. With useReducer, you can avoid passing down callbacks through different levels of your component. Instead, useReducer allows you to pass a provided dispatch function, which in turn will improve performance for components that trigger deep updates.

However, this does not imply that the useState updater function is newly called on each render. When you have a complex logic to update state, you simply won’t use the setter directly to update state. Instead, you’ll write a complex function, which in turn would call the setter with updated state.

Therefore, it’s recommended to use useReducer, which returns a dispatch method that doesn’t change between re-renders, and you can have the manipulation logic in the reducers.

It’s also worth noting that with useState, the state updater function is invoked to update state, but with useReducer, the dispatch function is invoked instead, and an action with at least a type is passed to it.

Now, let’s take a look at how both Hooks are declared and used.

Declaring state with useState

const [state, setState] = useState('default state');

useState returns an array that holds the current state value and a setStatemethod for updating the state.

Declaring state with useReducer

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

useReducer returns an array that holds the current state value and a dispatchmethod that logically achieves the same goal as setState, updating the state.

Updating state with useState is as follows:

<input type='text' value={state} onChange={(e) => setState(e.currentTarget.value)} />

Updating state with useReducer is as follows:

&lt;button onClick={() => dispatch({ type: 'decrement'})}>Decrement</button>

We’ll discuss the dispatch function in greater depth later in the tutorial. Optionally, an action object may also have a payload:

<button onClick={() => dispatch({ type: 'decrement', payload: 0})}>Decrement</button>

useReducer can be handy when managing complex state shape, for example, when the state consists of more than primitive values, like nested arrays or objects:

const [state, dispatch] = useReducer(loginReducer,
  {
    users: [
      { username: 'Philip', isOnline: false},
      { username: 'Mark', isOnline: false },
      { username: 'Tope', isOnline: true},
      { username: 'Anita', isOnline: false },
    ],
    loading: false,
    error: false,
  },
);

It’s easier to manage this local state because the parameters depend on each other, and all the logic could be encapsulated into one reducer.

When to use the useReducer Hook

As your application grows in size, you’ll most likely deal with more complex state transitions, at which point you’ll be better off using useReducer.

useReducer provides more predictable state transitions than useState, which becomes more important when state changes become so complex that you want to have one place to manage state, like the render function.

A good rule of thumb is that when you move past managing primitive data, i.e., a string, integer, or Boolean, and instead must manage a complex object, like with arrays and additional primitives, you’re likely better off with useReducer.

If you’re more of a visual learner, the video below gives a detailed explanation with practical examples of when to use the useReducer Hook.

Create a login component

For a better understating of when to use useReducer, let’s create a login component and compare how we’d manage state with both the useState and useReducer Hooks.

First, let’s create the login component with useState:

import React, { useState } from 'react';

export default function LoginUseState() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [isLoading, showLoader] = useState(false);
  const [error, setError] = useState('');
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const onSubmit = async (e) => {
    e.preventDefault();
    setError('');
    showLoader(true);
    try {
      await function login({ username, password }) {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            if (username === 'ejiro' && password === 'password') {
              resolve();
            } else {
              reject();
            }
          }, 1000);
        });
      }
      setIsLoggedIn(true);
    } catch (error) {
      setError('Incorrect username or password!');
      showLoader(false);
      setUsername('');
      setPassword('');
    }
  };
  return (
    <div className='App'>
      <div className='login-container'>
        {isLoggedIn ? (
          <>
            <h1>Welcome {username}!</h1>
            <button onClick={() => setIsLoggedIn(false)}>Log Out</button>
          </>
        ) : (
          <form className='form' onSubmit={onSubmit}>
            {error && <p className='error'>{error}</p>}
            <p>Please Login!</p>
            <input
              type='text'
              placeholder='username'
              value={username}
              onChange={(e) => setUsername(e.currentTarget.value)}
            />
            <input
              type='password'
              placeholder='password'
              autoComplete='new-password'
              value={password}
              onChange={(e) => setPassword(e.currentTarget.value)}
            />
            <button className='submit' type='submit' disabled={isLoading}>
              {isLoading ? 'Logging in...' : 'Log In'}
            </button>
          </form>
        )}
      </div>
    </div>
  );
}

Notice how we are dealing with all these state transitions, like username, password, isLoading, error, and isLoggedIn, when we really should be more focused on the action that the user wants to take on the login component.

We used five useState Hooks, and we had to worry about when each of these states is transitioned. We can refactor the code above to use useReducer and encapsulate all our logic and state transitions in one reducer function:

import React, { useReducer } from 'react';

function loginReducer(state, action) {
  switch (action.type) {
    case 'field': {
      return {
        ...state,
        [action.fieldName]: action.payload,
      };
    }
    case 'login': {
      return {
        ...state,
        error: '',
        isLoading: true,
      };
    }
    case 'success': {
      return {
        ...state,
        isLoggedIn: true,
        isLoading: false,
      };
    }
    case 'error': {
      return {
        ...state,
        error: 'Incorrect username or password!',
        isLoggedIn: false,
        isLoading: false,
        username: '',
        password: '',
      };
    }
    case 'logOut': {
      return {
        ...state,
        isLoggedIn: false,
      };
    }
    default:
      return state;
  }
}
const initialState = {
  username: '',
  password: '',
  isLoading: false,
  error: '',
  isLoggedIn: false,
};
export default function LoginUseReducer() {
  const [state, dispatch] = useReducer(loginReducer, initialState);
  const { username, password, isLoading, error, isLoggedIn } = state;
  const onSubmit = async (e) => {
    e.preventDefault();
    dispatch({ type: 'login' });
    try {
      await function login({ username, password }) {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            if (username === 'ejiro' && password === 'password') {
              resolve();
            } else {
              reject();
            }
          }, 1000);
        });
      }
      dispatch({ type: 'success' });
    } catch (error) {
      dispatch({ type: 'error' });
    }
  };
  return (
    <div className='App'>
      <div className='login-container'>
        {isLoggedIn ? (
          <>
            <h1>Welcome {username}!</h1>
            <button onClick={() => dispatch({ type: 'logOut' })}>
              Log Out
            </button>
          </>
        ) : (
          <form className='form' onSubmit={onSubmit}>
            {error && <p className='error'>{error}</p>}
            <p>Please Login!</p>
            <input
              type='text'
              placeholder='username'
              value={username}
              onChange={(e) =>
                dispatch({
                  type: 'field',
                  fieldName: 'username',
                  payload: e.currentTarget.value,
                })
              }
            />
            <input
              type='password'
              placeholder='password'
              autoComplete='new-password'
              value={password}
              onChange={(e) =>
                dispatch({
                  type: 'field',
                  fieldName: 'password',
                  payload: e.currentTarget.value,
                })
              }
            />
            <button className='submit' type='submit' disabled={isLoading}>
              {isLoading ? 'Logging in...' : 'Log In'}
            </button>
          </form>
        )}
      </div>
    </div>
  );
}

Notice how the new implementation with useReducer has made us more focused on the action the user is going to take. For example, when the login action is dispatched, we can see clearly what we want to happen. We want to return a copy of our current state, set our error to an empty string, and set isLoading to true:

case 'login': {
      return {
        ...state,
        error: '',
        isLoading: true,
      };
    }

The beautiful thing about our current implementation is that we no longer have to focus on state transition. Instead, we are keen on the actions to be executed by the user.

When not to use the useReducer Hook

Despite being able to use the useReducer Hook to handle complex state logic in our app, it’s important to note that there are certainly some scenarios where a third-party state management library like Redux may be a better option.

One simple answer is that you should avoid using Redux or any other third-party state management library until you have problems with vanilla React. If you’re still confused as to whether you need it, chances are, you likely don’t.

Let’s review some specific cases where it makes more sense to use a library like Redux or MobX.

When your application needs a single source of truth

Centralizing your application’s state and logic with a library like Redux makes creating universal application state a breeze because the state from the server can easily be serialized to the client app. Having a single source of truth also makes it easy to implement powerful capabilities like undo/redo features.

When you want a more predictable state

Using a library like Redux helps you write applications that behave consistently when running in different environments. If the same state and action are passed to a reducer, the same result is always produced because reducers are pure functions. Also, state in Redux is read-only, and the only way to change the state is to emit an action, an object describing what happened.

When state-lifting to the top-level component no longer suffices

Using a library like Redux is best when keeping everything in a top-level React component’s state is no longer sufficient.

State persistence

With libraries like Redux and MobX, you can easily save state to localStorage and make it available to end users, even after page refresh.

With all these benefits, it’s also worth noting that using a library like Redux, as opposed to using pure React with useReducer, comes with some tradeoffs. For example, Redux has a hefty learning curve that is minimized by using Redux Toolkit, and it’s definitely not the fastest way to write code. Rather, it’s intended to give you an absolute and predictable way of managing state in your app.

Conclusion

In this article, we explored React’s useReducer Hook in depth, reviewing how it works, when to use it, and comparing it to the useState Hook.

I hope you enjoyed this article, and be sure to leave a comment if you have any questions. Happy coding!

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Ejiro Asiuwhu Frontend engineer with rock-solid experience in building complex interactive applications with JavaScript, TypeScript, Vue.js, NuxtJS, React, Next.js, and other tools in the JS ecosystem. I regularly author meaningful technical content ✍🏽.

7 Replies to “React useReducer Hook ultimate guide”

  1. Really good explanation, thanks! Just starting to learn react and on top of a lot of new stuff to learn there is more new stuff to learn with the introduction to the Hooks.

    I am currently stuck. I am using useReducer because I have form with multiple inputs. I capture the inputs and call an API. I am getting the results back.

    The part I am stuck on is how to get that data into a table of sorts. But before I can do that I need to update the state with the data I get back (I think). I am having a hard time finding how to do that. Most examples just have everything on one page: the reducer, the axios call, the update to one field because they are using useState, and just display the results in a console log. That’s great but who does that? #1) Just call a get API on page load and #2) Just plop the data into the console log. While informative and helps with some understanding, it is this last piece of the puzzle that I am stuck on. I am a little frustrated at the moment. If you know of a site that shows this last piece of the puzzle of if you have an example somewhere please let me know. Thanks for your time and sharing this info for newbs!

  2. I had to alter the code for the code to work: while the await login function is declared, it is not called as a method. So refactoring to extract the | function login… | (which now you can make available to both the state and reducer examples):

    const handleOnSubmit = async (e) => {
    e.preventDefault();
    setError(”);
    showLoader(true);

    function login(u, p) {
    return new Promise((resolve, reject) => {
    console.log(‘fuc’);
    setTimeout(() => {
    if (u === ‘username’ && p === ‘password’) {
    resolve();
    } else {
    reject();
    }
    }, 1000);
    });
    }

    try {
    await login(username, password)
    setIsLoggedIn(true);
    } catch (error) {
    setError(‘Incorrect username or password!’);
    showLoader(false);
    setUsername(”);
    setPassword(”);
    }
    }

Leave a Reply