Paramclasses Academy - Blog

Tutorials Details

←
Post 18 of 29
→

How to Managing Complex State with the useReducer Hook in React.js ?

How to Managing Complex State with the useReducer Hook in React.js ?

October 30, 2026

1. Introduction

In React applications, useState is commonly used to manage simple state such as input values, counters, toggles, and form fields. However, as an application becomes more complex, state updates can involve multiple related operations and conditions.

For example, a shopping cart may need to add products, remove products, update quantities, and calculate the total price. Managing all these operations through multiple state variables and event handlers can become difficult to maintain.

React provides the useReducer Hook to organize state updates around a reducer function and a set of actions.

In this tutorial, we will learn how useReducer works, how to write reducer functions, how to dispatch actions, and how to build practical examples such as a counter, a form, and a shopping cart.

2. Learning Objectives

By the end of this tutorial, you will be able to:

  • Understand the purpose of the useReducer Hook.

  • Explain the difference between useState and useReducer.

  • Create a reducer function.

  • Understand state, actions, and dispatch.

  • Use useReducer to manage multiple related state updates.

  • Build a counter with multiple actions.

  • Manage form fields using a reducer.

  • Create a basic shopping cart state manager.

  • Follow best practices for predictable state updates.

  • Identify common mistakes when using reducers.

3. Core Concepts and Explanation

3.1 What Is the useReducer Hook?

useReducer is a React Hook that manages state using a reducer function. Instead of directly updating state through a setter function, you dispatch an action that describes what should happen.

Basic syntax:

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

Here:

  • state is the current state.

  • dispatch sends an action to the reducer.

  • reducer is the function that calculates the next state.

  • initialState is the starting state.

React calls the reducer with the current state and the dispatched action, then uses the returned value as the next state.

3.2 What Is a Reducer Function?

A reducer is a function that receives the current state and an action, then returns the next state.

function reducer(state, action) {
  switch (action.type) {
    case "INCREMENT":
      return state + 1;

    case "DECREMENT":
      return state - 1;

    default:
      return state;
  }
}

This reducer handles two actions:

  • INCREMENT increases the state by one.

  • DECREMENT decreases the state by one.

The default case returns the current state when no recognized action matches.

Important: A reducer should be a pure function. It should not directly mutate existing state, make API requests, or perform unrelated side effects.

3.3 What Is an Action?

An action describes the operation that should be performed on the state.

Actions are commonly represented as JavaScript objects:

{ type: "INCREMENT" }

An action can also carry additional data, usually called a payload:

{
  type: "ADD_PRODUCT",
  payload: {
    id: 1,
    name: "Keyboard",
    price: 1500
  }
}

The type identifies the operation. The payload provides any additional information required to perform it.

3.4 What Is Dispatch?

dispatch sends an action to the reducer.

dispatch({ type: "INCREMENT" });

When this action is dispatched, React processes the reducer and updates the component with the resulting state.

The general flow is:

User Interaction
       ↓
dispatch(action)
       ↓
Reducer(state, action)
       ↓
New State
       ↓
React Renders Updated UI

This separation makes state transitions easier to understand, test, and maintain.

3.5 Understanding the useReducer Lifecycle

Consider this example:

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

When a button calls:

dispatch({ type: "INCREMENT" });

React processes the action using the reducer. If the current count is 0, the reducer returns 1. React then updates the component using the new state.

You should not expect the count variable to change immediately within the same running event handler. React schedules and processes updates according to its rendering model.

3.6 Difference Between useState and useReducer

Feature useState useReducer
Best suited for Simple or independent state Complex or related state transitions
Update style State setter function Dispatch an action
Logic location Often inside event handlers Centralized in a reducer
Multiple operations Can become scattered Can be organized by action type
Testing transitions Possible Reducer can be tested independently
Boilerplate Usually less Usually more

Rule of thumb: Use useState when the state is simple. Consider useReducer when many related updates need centralized, structured logic.

Neither Hook is universally better; choose according to the complexity of the state.

4. Practical Code Examples

4.1 Example One: Building a Counter with useReducer

Create or update App.jsx:

import { useReducer } from "react";

function counterReducer(state, action) {
  switch (action.type) {
    case "INCREMENT":
      return state + 1;

    case "DECREMENT":
      return state - 1;

    case "RESET":
      return 0;

    default:
      return state;
  }
}

function App() {
  const [count, dispatch] = useReducer(counterReducer, 0);

  return (
    <div>
      <h1>useReducer Counter</h1>
      <h2>Count: {count}</h2>

      <button
        onClick={() => dispatch({ type: "INCREMENT" })}
      >
        Increase
      </button>

      <button
        onClick={() => dispatch({ type: "DECREMENT" })}
      >
        Decrease
      </button>

      <button
        onClick={() => dispatch({ type: "RESET" })}
      >
        Reset
      </button>
    </div>
  );
}

export default App;

How it works:

  1. The initial count is 0.

  2. Clicking Increase dispatches INCREMENT.

  3. The reducer returns the current count plus one.

  4. Clicking Decrease dispatches DECREMENT.

  5. Clicking Reset returns the count to zero.

The reducer contains the state transition rules, while the component focuses on user interactions and rendering.

4.2 Example Two: Passing Data Through Action Payloads

Reducers can handle actions that include additional values.

import { useReducer } from "react";

function counterReducer(state, action) {
  switch (action.type) {
    case "INCREMENT_BY":
      return state + action.payload;

    case "SET_VALUE":
      return action.payload;

    case "RESET":
      return 0;

    default:
      return state;
  }
}

function App() {
  const [count, dispatch] = useReducer(counterReducer, 0);

  return (
    <div>
      <h1>Counter with Payload</h1>
      <h2>{count}</h2>

      <button
        onClick={() =>
          dispatch({
            type: "INCREMENT_BY",
            payload: 5,
          })
        }
      >
        Increase by 5
      </button>

      <button
        onClick={() =>
          dispatch({
            type: "SET_VALUE",
            payload: 100,
          })
        }
      >
        Set to 100
      </button>

      <button onClick={() => dispatch({ type: "RESET" })}>
        Reset
      </button>
    </div>
  );
}

export default App;

Expected behavior:

  • The initial count is 0.

  • Increase by 5 changes it to 5.

  • Set to 100 changes it to 100.

  • Reset returns it to 0.

This pattern is useful whenever an action needs to carry a value, such as a product ID, quantity, form field, or search query.

4.3 Example Three: Managing Multiple Form Fields

A form with several fields can use a reducer to centralize field updates and reset behavior.

import { useReducer } from "react";

const initialState = {
  name: "",
  email: "",
  role: "student",
};

function formReducer(state, action) {
  switch (action.type) {
    case "UPDATE_FIELD":
      return {
        ...state,
        [action.field]: action.value,
      };

    case "RESET":
      return initialState;

    default:
      return state;
  }
}

function App() {
  const [form, dispatch] = useReducer(
    formReducer,
    initialState
  );

  function handleSubmit(event) {
    event.preventDefault();
    console.log("Submitted form:", form);
  }

  return (
    <div>
      <h1>Student Registration</h1>

      <form onSubmit={handleSubmit}>
        <label>
          Name:
          <input
            value={form.name}
            onChange={(event) =>
              dispatch({
                type: "UPDATE_FIELD",
                field: "name",
                value: event.target.value,
              })
            }
          />
        </label>

        <br />

        <label>
          Email:
          <input
            type="email"
            value={form.email}
            onChange={(event) =>
              dispatch({
                type: "UPDATE_FIELD",
                field: "email",
                value: event.target.value,
              })
            }
          />
        </label>

        <br />

        <label>
          Role:
          <select
            value={form.role}
            onChange={(event) =>
              dispatch({
                type: "UPDATE_FIELD",
                field: "role",
                value: event.target.value,
              })
            }
          >
            <option value="student">Student</option>
            <option value="developer">Developer</option>
          </select>
        </label>

        <br />

        <button type="submit">Submit</button>
        <button
          type="button"
          onClick={() => dispatch({ type: "RESET" })}
        >
          Reset
        </button>
      </form>

      <h2>Current Form Data</h2>
      <pre>{JSON.stringify(form, null, 2)}</pre>
    </div>
  );
}

export default App;

Key concepts:

  • The reducer manages the complete form object.

  • UPDATE_FIELD updates one field while preserving the others.

  • The spread operator creates a new object instead of mutating the existing state.

  • RESET restores the initial form values.

  • preventDefault() stops the browser from performing its normal form submission.

For a production registration form, add appropriate validation and submit the data to a secure backend endpoint.

4.4 Example Four: Building a Basic Shopping Cart Reducer

A shopping cart is a useful real-world example because it contains multiple related operations.

import { useReducer } from "react";

const initialCart = [];

function cartReducer(state, action) {
  switch (action.type) {
    case "ADD_ITEM": {
      const existingItem = state.find(
        (item) => item.id === action.payload.id
      );

      if (existingItem) {
        return state.map((item) =>
          item.id === action.payload.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      }

      return [
        ...state,
        { ...action.payload, quantity: 1 },
      ];
    }

    case "REMOVE_ITEM":
      return state.filter(
        (item) => item.id !== action.payload
      );

    case "INCREASE_QUANTITY":
      return state.map((item) =>
        item.id === action.payload
          ? { ...item, quantity: item.quantity + 1 }
          : item
      );

    case "DECREASE_QUANTITY":
      return state
        .map((item) =>
          item.id === action.payload
            ? { ...item, quantity: item.quantity - 1 }
            : item
        )
        .filter((item) => item.quantity > 0);

    case "CLEAR_CART":
      return [];

    default:
      return state;
  }
}

function App() {
  const [cart, dispatch] = useReducer(
    cartReducer,
    initialCart
  );

  const products = [
    { id: 1, name: "Keyboard", price: 1500 },
    { id: 2, name: "Mouse", price: 800 },
    { id: 3, name: "Headphones", price: 2500 },
  ];

  const total = cart.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  return (
    <div>
      <h1>Shopping Cart</h1>

      <h2>Products</h2>

      {products.map((product) => (
        <div key={product.id}>
          <span>
            {product.name} — ₹{product.price}
          </span>

          <button
            onClick={() =>
              dispatch({
                type: "ADD_ITEM",
                payload: product,
              })
            }
          >
            Add to Cart
          </button>
        </div>
      ))}

      <h2>Your Cart</h2>

      {cart.length === 0 ? (
        <p>Your cart is empty.</p>
      ) : (
        cart.map((item) => (
          <div key={item.id}>
            <span>
              {item.name} × {item.quantity}
            </span>

            <button
              onClick={() =>
                dispatch({
                  type: "INCREASE_QUANTITY",
                  payload: item.id,
                })
              }
            >
              +
            </button>

            <button
              onClick={() =>
                dispatch({
                  type: "DECREASE_QUANTITY",
                  payload: item.id,
                })
              }
            >
              -
            </button>

            <button
              onClick={() =>
                dispatch({
                  type: "REMOVE_ITEM",
                  payload: item.id,
                })
              }
            >
              Remove
            </button>
          </div>
        ))
      )}

      <h3>Total: ₹{total}</h3>

      <button
        onClick={() => dispatch({ type: "CLEAR_CART" })}
      >
        Clear Cart
      </button>
    </div>
  );
}

export default App;

How this reducer works:

  • ADD_ITEM adds a product or increments its quantity if it already exists.

  • REMOVE_ITEM removes a product by ID.

  • INCREASE_QUANTITY increments the selected product's quantity.

  • DECREASE_QUANTITY decrements the quantity and removes the item if it reaches zero.

  • CLEAR_CART empties the cart.

  • reduce() calculates the total price from the current cart state.

This is a learning example. A production e-commerce application should validate product prices and availability on the server rather than trusting client-side values.

4.5 Example Five: Testing a Reducer Independently

Because a reducer is a regular function, you can test it without rendering a React component.

function counterReducer(state, action) {
  switch (action.type) {
    case "INCREMENT":
      return state + 1;

    case "RESET":
      return 0;

    default:
      return state;
  }
}

console.log(
  counterReducer(5, { type: "INCREMENT" })
); // 6

console.log(
  counterReducer(5, { type: "RESET" })
); // 0

console.log(
  counterReducer(5, { type: "UNKNOWN" })
); // 5

This makes it easier to verify that each action produces the expected next state.

5. Real-World Applications

5.1 E-commerce Applications

Use a reducer to organize cart operations, quantity changes, item removal, and cart reset behavior.

5.2 Multi-Step Forms

A reducer can manage the current step, entered values, validation status, and navigation between steps.

5.3 Dashboards

Complex filters, selected items, sorting options, and UI preferences can be managed through clearly defined actions.

5.4 Task Management Systems

A reducer can manage adding tasks, completing tasks, editing task details, and deleting tasks.

5.5 Game State

For small games, a reducer can organize transitions such as starting a game, updating a score, advancing a level, or resetting progress.

6. Common Mistakes and Best Practices

Mistake 1: Mutating State Directly

Incorrect:

state.count += 1;
return state;

Correct:

return {
  ...state,
  count: state.count + 1,
};

For arrays, use methods such as map(), filter(), and spread syntax to create updated arrays.

Mistake 2: Forgetting to Return the New State

Every recognized reducer action should return the next state.

Best practice: Use a switch statement and include a default case that returns the existing state for unknown actions.

Mistake 3: Putting Side Effects Inside the Reducer

Reducers should not make API requests, write to the DOM, or perform other side effects.

Best practice: Handle side effects in event handlers, effects, or appropriate application layers, then dispatch actions to update state.

Mistake 4: Using Unclear Action Names

Action names such as DO_THING do not communicate intent clearly.

Best practice: Use descriptive action types, such as ADD_ITEM, REMOVE_ITEM, or UPDATE_FIELD.

Mistake 5: Choosing useReducer for Every State Variable

A simple toggle may not need a reducer.

Best practice: Prefer the simplest approach that keeps state transitions understandable.

Mistake 6: Forgetting That Dispatch Does Not Immediately Change the Current Variable

The state variable in the current render remains a snapshot of that render.

Best practice: Let React render the next state, and use the reducer's return value as the source of truth for the next render.

7. Practice Exercises

Exercise 1 — Counter Reducer

Build a counter with Increase, Decrease, Reset, and Increase by 10 actions.

Exercise 2 — Form Reducer

Create a registration form with name, email, and password fields. Manage updates and reset operations with a reducer.

Exercise 3 — Todo Reducer

Build a todo list supporting:

  • Add a task.

  • Mark a task as complete.

  • Edit a task.

  • Delete a task.

  • Clear completed tasks.

Exercise 4 — Shopping Cart

Extend the shopping cart example with a total item count and a discount action.

Exercise 5 — Product Filter

Create a reducer that manages a search query, selected category, and sorting order.

Exercise 6 — Reducer Testing

Write tests or simple assertions that verify every reducer action returns the expected state.

Exercise 7 — Architecture Challenge

Choose a component that has multiple related state updates. Explain whether useReducer would make its state transitions clearer than several useState calls.

8. Key Takeaways and Conclusion

In this tutorial, we learned that:

  • useReducer manages state through a reducer function and dispatched actions.

  • A reducer receives the current state and an action and returns the next state.

  • Actions describe what happened and may contain a payload.

  • dispatch() requests a state transition.

  • Reducers should be pure and should not mutate existing state.

  • useReducer is useful when state updates are complex or closely related.

  • Reducer functions can be tested independently.

  • useState remains a good choice for simple state.

Understanding useReducer helps you organize complex React state into predictable, reusable, and testable transitions.

←
Post 18 of 29
→