useReducer: When useState Isn't Enough

By Odilon12 min read

useReducer: When useState Isn't Enough#

tsx
// What's wrong with this code?
function DataTableToolbar() {
  const [searchQuery, setSearchQuery] = useState('');
  const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'archived'>('all');
  const [dateRange, setDateRange] = useState<{ from: Date | null; to: Date | null }>({ from: null, to: null });
  const [sortField, setSortField] = useState<string>('createdAt');
  const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
  const [currentPage, setCurrentPage] = useState(1);

  function handleSearchChange(query: string) {
    setSearchQuery(query);
    setCurrentPage(1); // reset page — easy to forget this
  }

  function handleStatusChange(status: typeof statusFilter) {
    setStatusFilter(status);
    setCurrentPage(1); // remember to reset here too
  }

  function handleSortChange(field: string) {
    if (field === sortField) {
      setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
    } else {
      setSortField(field);
      setSortDirection('asc');
    }
    setCurrentPage(1); // and here
  }

  function handleReset() {
    setSearchQuery('');
    setStatusFilter('all');
    setDateRange({ from: null, to: null });
    setSortField('createdAt');
    setSortDirection('desc');
    setCurrentPage(1);
    // did I get them all? honestly not sure
  }
}

Six useState calls. Four handler functions. The bug is inevitable: add a new filter and forget the setCurrentPage(1) call. Users set a date filter and see page 3 of the old results with zero rows because the new filtered set only had one page.

The setCurrentPage(1) scattered through every handler is a maintenance hazard. Every new filter means remembering to add it. This is business logic — "any filter change resets to page 1" — hiding in component logic.

That is when to reach for useReducer.

tsx
interface TableFilterState {
  searchQuery: string;
  statusFilter: 'all' | 'active' | 'archived';
  dateRange: { from: Date | null; to: Date | null };
  sortField: string;
  sortDirection: 'asc' | 'desc';
  currentPage: number;
}

type TableFilterAction =
  | { type: 'SET_SEARCH'; payload: string }
  | { type: 'SET_STATUS'; payload: TableFilterState['statusFilter'] }
  | { type: 'SET_DATE_RANGE'; payload: TableFilterState['dateRange'] }
  | { type: 'TOGGLE_SORT'; payload: string }
  | { type: 'SET_PAGE'; payload: number }
  | { type: 'RESET' };

const initialFilterState: TableFilterState = {
  searchQuery: '',
  statusFilter: 'all',
  dateRange: { from: null, to: null },
  sortField: 'createdAt',
  sortDirection: 'desc',
  currentPage: 1,
};

function tableFilterReducer(
  state: TableFilterState,
  action: TableFilterAction
): TableFilterState {
  switch (action.type) {
    case 'SET_SEARCH':
      return { ...state, searchQuery: action.payload, currentPage: 1 };

    case 'SET_STATUS':
      return { ...state, statusFilter: action.payload, currentPage: 1 };

    case 'SET_DATE_RANGE':
      return { ...state, dateRange: action.payload, currentPage: 1 };

    case 'TOGGLE_SORT': {
      if (action.payload === state.sortField) {
        return { ...state, sortDirection: state.sortDirection === 'asc' ? 'desc' : 'asc', currentPage: 1 };
      }
      return { ...state, sortField: action.payload, sortDirection: 'asc', currentPage: 1 };
    }

    case 'SET_PAGE':
      return { ...state, currentPage: action.payload };

    case 'RESET':
      return initialFilterState;

    default:
      return state;
  }
}

The business rule "any filter change resets to page 1" now lives in one place. Add a new filter type and you add one case to the switch. You cannot forget to reset the page because the reducer handles it. The bug I described becomes structurally impossible.

The component becomes clean:

tsx
function DataTableToolbar() {
  const [filterState, dispatch] = useReducer(tableFilterReducer, initialFilterState);

  return (
    <div className="toolbar">
      <input
        value={filterState.searchQuery}
        onChange={e => dispatch({ type: 'SET_SEARCH', payload: e.target.value })}
        placeholder="Search..."
      />
      <select
        value={filterState.statusFilter}
        onChange={e => dispatch({ type: 'SET_STATUS', payload: e.target.value as TableFilterState['statusFilter'] })}
      >
        <option value="all">All</option>
        <option value="active">Active</option>
        <option value="archived">Archived</option>
      </select>
      <button onClick={() => dispatch({ type: 'RESET' })}>
        Clear filters
      </button>
    </div>
  );
}

No more five setter functions. One dispatch. Done.

Now here is a shopping cart, because that is the other case where useState starts to fall apart:

tsx
interface CartItem {
  productId: string;
  name: string;
  price: number;
  quantity: number;
  imageUrl: string;
}

interface CartState {
  items: CartItem[];
  promoCode: string | null;
  discountPercent: number;
}

type CartAction =
  | { type: 'ADD_ITEM'; payload: Omit<CartItem, 'quantity'> }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'UPDATE_QUANTITY'; payload: { productId: string; quantity: number } }
  | { type: 'APPLY_PROMO'; payload: { code: string; discountPercent: number } }
  | { type: 'REMOVE_PROMO' }
  | { type: 'CLEAR_CART' };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existingIndex = state.items.findIndex(
        item => item.productId === action.payload.productId
      );

      if (existingIndex >= 0) {
        // already in cart — increment
        const updatedItems = state.items.map((item, index) =>
          index === existingIndex
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
        return { ...state, items: updatedItems };
      }

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

    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(item => item.productId !== action.payload),
      };

    case 'UPDATE_QUANTITY': {
      if (action.payload.quantity <= 0) {
        return {
          ...state,
          items: state.items.filter(item => item.productId !== action.payload.productId),
        };
      }
      return {
        ...state,
        items: state.items.map(item =>
          item.productId === action.payload.productId
            ? { ...item, quantity: action.payload.quantity }
            : item
        ),
      };
    }

    case 'APPLY_PROMO':
      return {
        ...state,
        promoCode: action.payload.code,
        discountPercent: action.payload.discountPercent,
      };

    case 'REMOVE_PROMO':
      return { ...state, promoCode: null, discountPercent: 0 };

    case 'CLEAR_CART':
      return { items: [], promoCode: null, discountPercent: 0 };

    default:
      return state;
  }
}

// Derived values — computed, not stored
function getCartTotals(cart: CartState) {
  const subtotal = cart.items.reduce(
    (sum, item) => sum + item.price * item.quantity, 0
  );
  const discount = subtotal * (cart.discountPercent / 100);
  const total = subtotal - discount;
  const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);

  return { subtotal, discount, total, itemCount };
}

The total is computed, not stored in state. Storing derived values in state is a bug waiting to happen — they will get out of sync with their source of truth eventually. If you can compute it, compute it.

Combine useReducer with useContext and you get a mini state management system without any external libraries:

tsx
interface CartContextValue {
  state: CartState;
  dispatch: React.Dispatch<CartAction>;
  totals: ReturnType<typeof getCartTotals>;
}

const CartContext = createContext<CartContextValue | undefined>(undefined);

export function CartProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, {
    items: [],
    promoCode: null,
    discountPercent: 0,
  });

  const totals = getCartTotals(state);

  return (
    <CartContext.Provider value={{ state, dispatch, totals }}>
      {children}
    </CartContext.Provider>
  );
}

export function useCart(): CartContextValue {
  const context = useContext(CartContext);
  if (!context) throw new Error('useCart must be used within CartProvider');
  return context;
}

// convenience hook — wraps dispatch with named functions
export function useCartActions() {
  const { dispatch } = useCart();

  return {
    addItem: (product: Omit<CartItem, 'quantity'>) =>
      dispatch({ type: 'ADD_ITEM', payload: product }),
    removeItem: (productId: string) =>
      dispatch({ type: 'REMOVE_ITEM', payload: productId }),
    updateQuantity: (productId: string, quantity: number) =>
      dispatch({ type: 'UPDATE_QUANTITY', payload: { productId, quantity } }),
    clearCart: () =>
      dispatch({ type: 'CLEAR_CART' }),
  };
}

Now any component can access the cart:

tsx
function CartSummaryBadge() {
  const { totals } = useCart();

  return (
    <div className="cart-badge">
      {totals.itemCount > 0 && (
        <span className="cart-badge__count">{totals.itemCount}</span>
      )}
    </div>
  );
}

function ProductCard({ product }: { product: Product }) {
  const { addItem } = useCartActions();

  return (
    <div className="product-card">
      <img src={product.imageUrl} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price.toFixed(2)}</p>
      <button onClick={() => addItem(product)}>Add to Cart</button>
    </div>
  );
}

And here is the thing that makes reducers really worth it — they are trivially testable. Pure function, no React, no DOM, no mocks:

tsx
describe('cartReducer', () => {
  const initialState: CartState = { items: [], promoCode: null, discountPercent: 0 };

  it('adds a new item with quantity 1', () => {
    const action: CartAction = {
      type: 'ADD_ITEM',
      payload: { productId: 'p1', name: 'Widget', price: 29.99, imageUrl: '/img.jpg' },
    };
    const result = cartReducer(initialState, action);
    expect(result.items).toHaveLength(1);
    expect(result.items[0].quantity).toBe(1);
  });

  it('increments quantity for an existing item', () => {
    const stateWithItem: CartState = {
      ...initialState,
      items: [{ productId: 'p1', name: 'Widget', price: 29.99, imageUrl: '/img.jpg', quantity: 2 }],
    };
    const action: CartAction = {
      type: 'ADD_ITEM',
      payload: { productId: 'p1', name: 'Widget', price: 29.99, imageUrl: '/img.jpg' },
    };
    const result = cartReducer(stateWithItem, action);
    expect(result.items[0].quantity).toBe(3);
  });
});

My heuristic for when to switch: the moment a component has more than three or four useState calls that are conceptually related, it is time for useReducer. The benefits compound. The state machine is explicit, the transitions are named, and the business rules live in the reducer instead of scattered across event handlers.

The useReducer + useContext combination covers a surprising amount of the use cases that people reach for Redux to solve. Redux still makes sense for time-travel debugging, middleware for async actions, or very large teams that need the structure. But for most mid-sized apps? Built-in hooks are enough.