Tech Verse Logo
Enable dark mode
React useReducer: When useState Stops Scaling

React useReducer: When useState Stops Scaling

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

4 min read

The Breaking Point of useState

Every React developer starts with useState. It works great when you're toggling a modal, tracking a text input, or managing a counter. But as components grow, useState starts to break down under the weight of interdependent state fields. If you've ever written code where updating one piece of state requires manually triggering three other setters in sequence, you've hit the boundary.

Consider a dashboard data grid. You have pagination, sorting, search queries, row selections, and network status. When a user updates the search filter, you need to reset the page index to 0, clear selected rows, set the loading state, and clear previous errors. Missing one of those updates introduces bug states where the table displays page 5 of a single-page search result, or keeps stale row selections from a previous search. You end up with impossible state combinations like isLoading: false while error: null and data: [], leaving your UI stuck in blank render loops.

Extracting Logic into a Pure Reducer

The React 19 useReducer hook solves this by moving state mutation logic out of your component body and into a pure function. Instead of telling React how to change five individual variables across separate setter calls, you dispatch an action describing what happened in your application.

Here's a implementation of a complex table state reducer using TypeScript and React 19 types:

type TableState = {
  page: number;
  pageSize: number;
  searchQuery: string;
  selectedIds: string[];
  status: 'idle' | 'loading' | 'success' | 'error';
  errorMessage: string | null;
};

type TableAction =
  | { type: 'SEARCH_CHANGED'; payload: string }
  | { type: 'PAGE_CHANGED'; payload: number }
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS' }
  | { type: 'FETCH_ERROR'; payload: string }
  | { type: 'TOGGLE_ROW'; payload: string };

export const initialTableState: TableState = {
  page: 0,
  pageSize: 25,
  searchQuery: '',
  selectedIds: [],
  status: 'idle',
  errorMessage: null,
};

export function tableReducer(state: TableState, action: TableAction): TableState {
  switch (action.type) {
    case 'SEARCH_CHANGED':
      return {
        ...state,
        searchQuery: action.payload,
        page: 0,
        selectedIds: [],
      };
    case 'PAGE_CHANGED':
      return {
        ...state,
        page: action.payload,
      };
    case 'FETCH_START':
      return {
        ...state,
        status: 'loading',
        errorMessage: null,
      };
    case 'FETCH_SUCCESS':
      return {
        ...state,
        status: 'success',
      };
    case 'FETCH_ERROR':
      return {
        ...state,
        status: 'error',
        errorMessage: action.payload,
      };
    case 'TOGGLE_ROW': {
      const id = action.payload;
      const exists = state.selectedIds.includes(id);
      return {
        ...state,
        selectedIds: exists
          ? state.selectedIds.filter((item) => item !== id)
          : [...state.selectedIds, id],
      };
    }
    default:
      return state;
  }
}

Notice how SEARCH_CHANGED encapsulates three distinct updates. The component dispatching the action doesn't care about setting page numbers back to zero or clearing array selections. It just reports that the search input changed. This keeps event handlers lean and moves business logic into a deterministic, testable module.

Unit Testing Reducers Without React

One of the largest hidden costs of useState is testing difficulty. Testing complex state transitions driven by useState usually requires @testing-library/react or renderHook, setting up mock providers, firing synthetic events, and waiting for asynchronous re-renders. It's slow and brittle.

Because a reducer is just a JavaScript function that takes state and an action and returns a new state, you don't need React to test it. You can write plain Vitest or Jest unit tests that run in under 5ms.

import { describe, it, expect } from 'vitest';
import { tableReducer, initialTableState } from './tableReducer';

describe('tableReducer', () => {
  it('resets page and selection when search query changes', () => {
    const activeState = {
      ...initialTableState,
      page: 4,
      selectedIds: ['row-1', 'row-2'],
      searchQuery: 'old query',
    };

    const nextState = tableReducer(activeState, {
      type: 'SEARCH_CHANGED',
      payload: 'new query',
    });

    expect(nextState.page).toBe(0);
    expect(nextState.selectedIds).toEqual([]);
    expect(nextState.searchQuery).toBe('new query');
  });

  it('toggles row selection correctly', () => {
    const stateWithOneSelected = tableReducer(initialTableState, {
      type: 'TOGGLE_ROW',
      payload: 'row-100',
    });
    expect(stateWithOneSelected.selectedIds).toEqual(['row-100']);

    const stateDeselected = tableReducer(stateWithOneSelected, {
      type: 'TOGGLE_ROW',
      payload: 'row-100',
    });
    expect(stateDeselected.selectedIds).toEqual([]);
  });
});

This test suite executes without mounting a single DOM node. When your state machine logic breaks, your unit test catches it immediately at the exact action handler responsible, without wading through React component life cycles or render queue delays.

Common Pitfalls and Gotchas

While react usereducer simplifies complex components, engineers often trip up on three key details when migrating away from useState.

1. Attempting Async Operations Inside the Reducer

Reducers must remain pure functions. You cannot make fetch requests, read from local storage, or dispatch secondary actions inside a reducer function. Reducers take state and action, then synchronously compute the next state. If you put side effects in your reducer, React 19 Strict Mode double-invocations will execute those side effects twice, causing duplicate network requests or race conditions.

Keep side effects in your event handlers or custom hooks. Trigger the fetch in your handler, dispatch FETCH_START, and then dispatch FETCH_SUCCESS or FETCH_ERROR when the promise settles.

2. Direct State Mutation

It's easy to accidentally mutate nested objects in state instead of returning new references. In React 19, direct mutations cause silent render failures because React compares object references to decide whether to trigger a re-render. Always return new object and array references. If your state tree has deep nesting, consider flattening your state structure or using standard spread operations carefully.

3. Modeling Actions as Setters Instead of Events

An anti-pattern is creating reducers with action types like SET_PAGE, SET_SEARCH, and SET_SELECTED_IDS. If your actions are just glorified useState setters, you haven't gained anything. Group related state changes into domain events like FILTER_APPLIED or CHECKOUT_FAILED so that one action transitions all affected state keys together.

Choosing Between useState and useReducer

Don't reach for react usereducer for every component. If your state consists of independent scalar values like const [isOpen, setIsOpen] = useState(false), single useState calls are clean and readable. But as soon as two or more state fields depend on each other, or your state transition rules require unit testing outside the DOM, useReducer is the right architectural choice.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    LLM API Pricing Comparison for Side Projects

    LLM API Pricing Comparison for Side Projects

    Building Semantic Search with Laravel 12 and Next.js 16

    Building Semantic Search with Laravel 12 and Next.js 16

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Wiring LLMs with Tool Function Calling

    Wiring LLMs with Tool Function Calling

    LLM API Rate Limit Caching and Quota Guarding

    LLM API Rate Limit Caching and Quota Guarding

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Pgvector Similarity Search in Production Postgres

    Pgvector Similarity Search in Production Postgres

    React Drag and Drop with dnd-kit and Laravel

    React Drag and Drop with dnd-kit and Laravel

    Building a Production RAG Pipeline in PHP 8.3

    Building a Production RAG Pipeline in PHP 8.3

    Laravel LLM Integration: Queues and Real-Time Frontend UI

    Laravel LLM Integration: Queues and Real-Time Frontend UI