React: Managing Complex UI State With Reducers

Introduction

React’s useState hook is an excellent choice for simple state. As components grow, however, multiple state variables may become tightly coupled.

A multi-step form, shopping cart, filter panel, or editor may need several values to change together. In those cases, useReducer can make state transitions explicit and easier to test.

When Should You Use a Reducer?

Consider a checkout component that stores the current step, shipping method, delivery address, submission status, and validation errors.

If every event updates these values independently, invalid combinations can become easy to create.

A reducer centralizes how state changes in response to actions. It is useful when transitions are more complex than setting a single value.

Define State and Actions

Start with a clear state shape:

const initialState = {
  step: "address",
  address: "",
  shippingMethod: "standard",
  status: "idle",
  error: null,
};

Next, define the reducer:

function checkoutReducer(state, action) {
  switch (action.type) {
    case "addressChanged":
      return {
        ...state,
        address: action.value,
        error: null,
      };

    case "shippingChanged":
      return {
        ...state,
        shippingMethod: action.value,
      };

    case "continue":
      if (!state.address.trim()) {
        return {
          ...state,
          error: "Enter a delivery address.",
        };
      }

      return {
        ...state,
        step: "shipping",
        error: null,
      };

    case "submitting":
      return {
        ...state,
        status: "submitting",
        error: null,
      };

    case "failed":
      return {
        ...state,
        status: "idle",
        error: action.message,
      };

    case "completed":
      return {
        ...state,
        status: "success",
        error: null,
      };

    default:
      return state;
  }
}

Each action represents an event that the component understands. The reducer computes the next state without performing side effects.

Connect the Reducer to a Component

import { useReducer } from "react";

export function CheckoutAddress() {
  const [state, dispatch] = useReducer(
    checkoutReducer,
    initialState
  );

  return (
    <section>
      <label>
        Delivery address
        <input
          value={state.address}
          onChange={(event) =>
            dispatch({
              type: "addressChanged",
              value: event.target.value,
            })
          }
        />
      </label>

      {state.error && (
        <p role="alert">{state.error}</p>
      )}

      <button
        type="button"
        onClick={() => dispatch({ type: "continue" })}
      >
        Continue
      </button>
    </section>
  );
}

The component dispatches actions, while the reducer determines how the state changes. This makes transition logic easier to inspect than a collection of unrelated setter calls.

Keep Side Effects Outside the Reducer

Reducers should be pure functions. They should not perform network requests, write to storage, or generate random values.

An asynchronous operation can dispatch actions before and after a request:

async function submitCheckout() {
  dispatch({ type: "submitting" });

  try {
    await checkoutApi.submit({
      address: state.address,
      shippingMethod: state.shippingMethod,
    });

    dispatch({ type: "completed" });
  } catch {
    dispatch({
      type: "failed",
      message: "Checkout could not be completed.",
    });
  }
}

This is an illustrative fragment. Production code should account for duplicate submissions, cancellation, and state changes while the request is running. Depending on the application, a custom hook or dedicated data layer may be a better place for the asynchronous operation.

Avoid a Reducer for Everything

A reducer is not automatically better than useState. For a component with one independent boolean and one input value, separate state variables are often clearer.

A reducer becomes valuable when several fields change together, transitions contain business rules, or state logic is difficult to reason about.

Choose the simplest model that accurately represents the component’s behavior.

Test Reducers Directly

Because reducers are pure functions, they can be tested without rendering React.

const state = {
  ...initialState,
  address: "",
};

const nextState = checkoutReducer(state, {
  type: "continue",
});

console.assert(
  nextState.error === "Enter a delivery address."
);

In a real project, use your test framework’s assertions rather than console.assert.

Conclusion

useReducer organizes complex state by making transitions explicit. It provides a predictable place to encode rules while keeping side effects separate.

Use it when the complexity of the state justifies it. A reducer should simplify the component’s mental model, not add abstraction without a clear benefit.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *