React|React operation principles and state management

useState vs. useReducer : The Tool to Choose When State Gets Complex

7
useState vs. useReducer : The Tool to Choose When State Gets Complex

Chris is building a shopping cart feature for an e-commerce site. At first, it was simple. A single array to hold the product list was enough.

const [cart, setCart] = useState<Product[]>([]);

But then, the requirements started piling up.

  • "Show a loading spinner while adding items." (isLoading)
  • "Display an error message if something fails." (error)
  • "Calculate and show the total price in real-time." (totalAmount)
  • "Recalculate the price if a discount coupon is applied." (discount)
  • Before he knew it, Chris's component was plastered with useState calls, and the code for updating state was scattered everywhere. Bugs started popping up, like forgetting to turn off the loading state or missing an error reset.

    "Why is state management so complicated? Can't I handle this all in one place?"

    This is the exact moment to pull out useReducer.

    1. The Limitations of useState: Scattered Logic

    useState is best for handling independent values (numbers, strings, simple booleans). However, problems arise when multiple states change dependently on each other.

    // ❌ Bad Example: State logic is hidden inside event handlers
    const handleAddProduct = (product) => {
      setIsLoading(true);
      setError(null);
      
      api.addToCart(product)
        .then((newCart) => {
          setCart(newCart);
          setTotal(calculateTotal(newCart)); // Total calculation logic is here...
          setIsLoading(false);
        })
        .catch((err) => {
          setError(err.message);
          setIsLoading(false);
        });
    };
    
    const handleDeleteProduct = (id) => {
      // We have to write total calculation logic again when deleting (Duplication!)
      // ...
    };

    The logic for "what to change and how" is fragmented across various functions inside the component. Anyone reading this code has to play hide-and-seek to find every setCart call.

    2. useReducer: The Central Control Room for State

    useReducer is a tool that separates state update logic from the component. Simply put, it's like an "accounting ledger" system.

  • Component (Employee): "Don't take money directly from the safe. Instead, fill out a 'Slip (Action)' and submit it."
  • Reducer (Accountant): "When I receive a slip, I update the Ledger (State) according to set rules."
  • Let's refactor Chris's shopping cart code using useReducer.

    Step 1: Define Action and State (TypeScript)

    First, define "what can happen."

    // types.ts
    type CartState = {
      cart: Product[];
      total: number;
      isLoading: boolean;
      error: string | null;
    };
    
    // List all possible events (Actions)
    type CartAction = 
      | { type: 'ADD_START' }
      | { type: 'ADD_SUCCESS'; payload: Product[] }
      | { type: 'ADD_FAIL'; payload: string }
      | { type: 'DELETE_ITEM'; payload: number }; // id

    Step 2: Write the Reducer Function (Centralized Logic)

    All state change logic gathers in this single function.

    // cartReducer.ts
    function cartReducer(state: CartState, action: CartAction): CartState {
      switch (action.type) {
        case 'ADD_START':
          return { ...state, isLoading: true, error: null };
          
        case 'ADD_SUCCESS':
          const newCart = action.payload;
          return { 
            ...state, 
            isLoading: false, 
            cart: newCart,
            total: calculateTotal(newCart) // Calculation logic lives here, once!
          };
          
        case 'ADD_FAIL':
          return { ...state, isLoading: false, error: action.payload };
          
        default:
          return state;
      }
    }

    Step 3: Use in Component (Dispatch)

    Now, the component doesn't need to know the logic. It only needs to send a dispatch (request).

    // CartComponent.tsx
    function CartComponent() {
      const [state, dispatch] = useReducer(cartReducer, initialState);
    
      const handleAddProduct = (product) => {
        dispatch({ type: 'ADD_START' }); // Report "Started"
        
        api.addToCart(product)
          .then(res => dispatch({ type: 'ADD_SUCCESS', payload: res }))
          .catch(err => dispatch({ type: 'ADD_FAIL', payload: err.message }));
      };
    
      return (
        <div>
          {state.isLoading && <Spinner />}
          {state.error && <ErrorMsg msg={state.error} />}
        </div>
      );
    }

    3. When Should You Use Which?

    So, is useReducer always better? No. It has the disadvantage of requiring more boilerplate code. Let's establish clear criteria.

    CriteriauseStateuseReducer
    Data TypePrimitives (number, string, boolean)Complex Objects, Arrays
    State Count1~2 independent statesMultiple related states
    Logic LocationInside event handlersOutside component (reducer function)
    DependencyNot dependent on previous stateNext state depends heavily on previous state

    ✅ Chris's Selection Criteria

  • useState: Input values in forms, modal isOpen toggles, simple counters.
  • useReducer: Data fetching states (loading, success, error set), complex form validation, shopping cart logic.
  • Key Takeaways

  • Managing Complexity: If state change logic is scattered and hard to manage, consider gathering it in one place with useReducer.
  • Readability: Code like dispatch({ type: 'LOGIN_SUCCESS' }) makes it explicitly clear "what happened" just by reading it.
  • Testability: Since the Reducer is a pure function, it is very easy to test the logic in isolation without the UI.

  • Now that we know how to manage state, it's time to handle Forms that interact with users. There are two ways to handle input; which side are you on?

    Continuing in: "Controlled vs. Uncontrolled Components: The Secret to How React Hook Form Reduces Rendering."

    🔗 References

  • React Docs - useReducer
  • React Docs - Extracting State Logic into a Reducer