Marouane Souda
⬅ Go back to blogElevate Your React State Management: When to Choose useReducer Over useState

Updated on

Elevate Your React State Management: When to Choose useReducer Over useState

Managing state is a fundamental aspect of building robust React applications. While the useState hook is perfect for handling simple state variables, complex state logic can quickly become cumbersome and error-prone, like when managing multiple useState instances (imagine more than 6).

This is where the useReducer hook comes in, offering a more structured and maintainable approach to state management.

The Limitations of useState in Complex Scenarios

Consider a scenario where you're building a signup form with multiple fields and validation requirements. Unless you are using a form library to handle complex form state like React Hook Form (I highly recommend it, it's been a life changer for me), using useState to manage each field and its associated state might look like this:

import { useState } from 'react';

function SignupForm() {
  const [username, setUsername] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState(null);
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsSubmitting(true);
    setError(null);

    try {
      // Simulate API call
      await new Promise((res) => setTimeout(res, 1000));
      alert('Signup successful!');
    } catch (err) {
      setError('Signup failed!');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Username" />
      <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
      {error && <p>{error}</p>}
      <button type="submit" disabled={isSubmitting}>Submit</button>
    </form>
  );
}

In this example, managing multiple useState hooks for each piece of state can lead to a cluttered and hard-to-maintain component, especially as the form grows in complexity.

Embracing useReducer for Complex State Management

Refactoring the above form to use useReducer can lead to a more organized and scalable solution. Here's how you can implement it:

import { useReducer } from 'react';

const initialState = {
  username: '',
  email: '',
  password: '',
  error: null,
  isSubmitting: false,
};

function reducer(state, action) {
  switch (action.type) {
    case 'SET_FIELD':
      return {
        ...state,
        [action.payload.field]: action.payload.value,
      };
    case 'SUBMIT_START':
      return {
        ...state,
        isSubmitting: true,
        error: null,
      };
    case 'SUBMIT_SUCCESS':
      return {
        ...state,
        isSubmitting: false,
      };
    case 'SUBMIT_ERROR':
      return {
        ...state,
        isSubmitting: false,
        error: action.payload.error,
      };
    default:
      return state;
  }
}

function SignupForm() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { username, email, password, error, isSubmitting } = state;

  const handleChange = (e) => {
    dispatch({
      type: 'SET_FIELD',
      payload: {
        field: e.target.name,
        value: e.target.value,
      },
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    dispatch({ type: 'SUBMIT_START' });

    try {
      await new Promise((res) => setTimeout(res, 1000));
      dispatch({ type: 'SUBMIT_SUCCESS' });
      alert('Signup successful!');
    } catch {
      dispatch({ type: 'SUBMIT_ERROR', payload: { error: 'Signup failed!' } });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="username" value={username} onChange={handleChange} placeholder="Username" />
      <input name="email" value={email} onChange={handleChange} placeholder="Email" />
      <input type="password" name="password" value={password} onChange={handleChange} placeholder="Password" />
      {error && <p>{error}</p>}
      <button type="submit" disabled={isSubmitting}>Submit</button>
    </form>
  );
}

By centralizing state management in a reducer function, the component becomes cleaner and easier to maintain. Each action clearly defines the intent, making the state transitions predictable and debuggable.

Now let's look at this new solution and analyze what we've done step-by-step:

1. Create an initial state

First of all, we removed all the component states defined with useState, and moved them to one central state object called initialState. That part is easy enough.

const initialState = {
  username: '',
  email: '',
  password: '',
  error: null,
  isSubmitting: false,
};

Keep in mind that it is outside of our component definition.

2. Define your reducer function

This is where you put all of your state logic. A reducer is just a function that takes in the current state and an action, and returns the new state.

If you didn't understand the part above, it's ok, I didn't get it at first either, so let's break it further down.

1. State

This is the state object with its initial value, before any updates to it:

const initialState = {
  username: '',
  email: '',
  password: '',
  error: null,
  isSubmitting: false,
};

2. Action

This is an object describing what happened. It must have a type property. For example:

{ type: "SUMBIT_START" }

It can also have another optional property payload holding the specific value to update the state with.

{
  type: "SET_FIELD",
  payload: {
    field: "username",
    value: "Marouane",
  }
}

3. Return value

the function must return a new state object (not mutate the old one).

Here's the final output of the reducer function:

function reducer(state, action) {
  switch (action.type) {
    case 'SET_FIELD':
      return {
        ...state,
        [action.payload.field]: action.payload.value,
      };
    case 'SUBMIT_START':
      return {
        ...state,
        isSubmitting: true,
        error: null,
      };
    case 'SUBMIT_SUCCESS':
      return {
        ...state,
        isSubmitting: false,
      };
    case 'SUBMIT_ERROR':
      return {
        ...state,
        isSubmitting: false,
        error: action.payload.error,
      };
    default:
      return state;
  }
}

Note that depending on the action type, we are updating the different properties of our state, and using payload values to do that, though not always.

For example, the SUBMIT_START action type only describes the start of submitting state, so we only update isSubmitting field to true, while the SET_FIELD action type means we are updating a form field value, for example the username field, and for that we need the actual field and its value to update the field with, which we carry in the action payload object.

Another thing to note is that we are not mutating the state object, but returning a new one (via object destructuring), so React can reactively rerender our component with the new change.

4. Replace useState with useReducer

Now, Instead of this:

const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);

We do this:

const [state, dispatch] = useReducer(reducer, initialState);
const { username, email, password, error, isSubmitting } = state;

Amazing! we just replaced 5 useState calls in 5 different lines with just one line of code thanks to useReducer (the destructuring of state object in optional).

Now we can access our state with the state object, and make changes to it with the dispatch function. The dispatch function accepts the action object we mentioned earlier like this:

dispatch({
  type: 'SET_FIELD',
  payload: {
    field: e.target.name,
    value: e.target.value,
  },
});

It is our alternative to setState function to update the state. Instead of setUsername("new username"), we can just call dispatch with the above format.

As we can see, while useReducer is definitely more complex than useState, it makes our code way more manageable and offers a new, scalable way to deal with our component state as it gets more complicated.

Important Notice About Action Types

While naming our action types directly in our reducer function will work just fine, you should avoid using direct strings to avoid typos and silly bugs that can be caused by mistyping the action type.

For example, this code below will not work because of the missing E in SET_FIELD.

dispatch({
  type: 'SET_FILD',
  payload: {
    field: e.target.name,
    value: e.target.value,
  },
});

This stupid typo can derail your project's progress for days or weeks even (ask me how I know...).

Instead, what you should do is create a new actions types object (or enum, if you're using TypeScript), and define your types there, like this:

const actionTypes = Object.freeze({
  SET_FIELD: 'SET_FIELD',
  SUBMIT_START: 'SUBMIT_START',
  SUBMIT_SUCCESS: 'SUBMIT_SUCCESS',
  SUBMIT_ERROR: 'SUBMIT_ERROR',
})

Object.freeze() method freezes an object, meaning it prevents extending it, and you can't add new properties or delete existing ones, or editing them. It's perfect for our case.

Now, use actionTypes inside your dispatch function like this:

dispatch({
  type: actionTypes.SET_FIELD,
  payload: {
    field: e.target.name,
    value: e.target.value,
  },
});

And in the reducer function:

function reducer(state, action) {
  switch (action.type) {
    case actionTypes.SET_FIELD:
      return {
        ...state,
        [action.payload.field]: action.payload.value,
      };
    case actionTypes.SUBMIT_START:
      return {
        ...state,
        isSubmitting: true,
        error: null,
      };
    case actionTypes.SUBMIT_SUCCESS:
      return {
        ...state,
        isSubmitting: false,
      };
    case actionTypes.SUBMIT_ERROR:
      return {
        ...state,
        isSubmitting: false,
        error: action.payload.error,
      };
    default:
      return state;
  }
}

It's much cleaner now, and error-proof. It's save you hours of debugging, so you should definitely use this approach (I learned my lesson the hard way).

BONUS: useReducer with TypeScript

To add type safety to our useReducer hook, we can take advantage of TypeScript's discriminated unions to add robust types to our action object.

First, we can replaced our actionTypes object with an enum:

enum ActionTypes {
  SET_FIELD = 'SET_FIELD',
  SUBMIT_START = 'SUBMIT_START',
  SUBMIT_SUCCESS = 'SUBMIT_SUCCESS',
  SUBMIT_ERROR = 'SUBMIT_ERROR',
}

Here is our type for action:

type Action = {
  type: "ActionTypes.SET_FIELD";
  payload: {
    field: "username" | "email" | "password";
    value: string;
  }
}
| {
    type: "ActionTypes.SUBMIT_START";
}
| {
    type: "ActionTypes.SUBMIT_SUCCESS";
}
| {
    type: "ActionTypes.SUBMIT_ERROR"; 
    payload: {
      error: string;
    }
}

We took advantage of TypeScript's discriminated unions to create a robust type for our action object. I wrote an extensive article about discriminated unions if you want to take a look, and I highly recommend that you do so.

And in the end, we have our simple State type:

type State = {
  username: string;
  email: string;
  password: string;
  isSubmitting: boolean;
  error: string | null;
}

And now, let's annotate our reducer funciton with the appropriate types:

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case ActionTypes.SET_FIELD:
      return {
        ...state,
        [action.payload.field]: action.payload.value,
      };
    case ActionTypes.SUBMIT_START:
      return {
        ...state,
        isSubmitting: true,
        error: null,
      };
    case ActionTypes.SUBMIT_SUCCESS:
      return {
        ...state,
        isSubmitting: false,
      };
    case ActionTypes.SUBMIT_ERROR:
      return {
        ...state,
        isSubmitting: false,
        error: action.payload.error,
      };
    default:
      return state;
  }
}

And congratulations! Now you have type safety in your application. You can now use VSCode's autocompletion to write your dispatch functions without worrying that you'll dispatch a non-existing action type because TypeScript will scream in your face to let you know about it.

When to Choose useReducer Over useState

  • Complex State Logic: When state transitions involve multiple sub-values or the next state depends on the previous one, useReducer provides a structured approach to handle such complexity.

  • Related State Updates: If multiple state variables are related and should be updated together, managing them with useReducer ensures consistency and reduces the risk of bugs.

  • Scalability: As your component grows, having a centralized reducer function makes it easier to manage and scale the state logic without cluttering the component with multiple useState calls.

In conclusion, while useState is suitable for simple state management, embracing useReducer for complex state scenarios can lead to more maintainable and scalable React components. By thoughtfully choosing the appropriate hook for your state management needs, you can enhance the clarity and robustness of your applications.