Skip to main content

⚙️ useReducer Hook

Welcome to Module 5! You've mastered useState, but what happens when your state logic gets complicated? When you have multiple pieces of related state that need to update together? When the next state depends on the previous state in complex ways? That's where useReducer shines! The useReducer Hook is like useState's more sophisticated older sibling - it helps you manage complex state logic in a predictable, testable way. If you've ever felt overwhelmed by managing multiple useState calls or struggled with state updates that depend on each other, you're about to discover a powerful solution. Let's level up your state management game! 🚀

🎯 Learning Objectives

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

  • Understand when to choose useReducer over useState
  • Grasp the reducer pattern and its benefits
  • Create reducer functions with proper TypeScript typing
  • Define action types and action creators
  • Implement useReducer for complex state management
  • Handle multiple related state updates with a single reducer
  • Type reducers, actions, and state correctly with TypeScript
  • Refactor useState logic to useReducer when appropriate
  • Debug reducer logic and understand common patterns

Estimated Time: 75-90 minutes

Project: Refactor a todo application from useState to useReducer

📑 In This Lesson

⚙️ Introduction to useReducer

The useReducer Hook is an alternative to useState for managing state in React components. While useState is perfect for simple state, useReducer excels when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one in non-trivial ways.

📖 Definition

useReducer: A React Hook that lets you manage state through a reducer function. It accepts a reducer function and an initial state, and returns the current state paired with a dispatch function to trigger state updates.

Why useReducer?

Think of useReducer as a more structured way to manage state. Instead of directly setting state values, you dispatch actions that describe what happened, and a reducer function determines how the state should change. This pattern brings several benefits:

✅ Benefits of useReducer

  • Predictable State Updates - All state changes happen in one place (the reducer)
  • Easier to Test - Reducers are pure functions that are simple to unit test
  • Better for Complex Logic - When state updates involve multiple pieces of state
  • Action History - Easy to log actions for debugging or implement undo/redo
  • Centralized Logic - All state update logic lives in the reducer, not scattered across components
  • Type Safety - TypeScript can ensure you're dispatching valid actions

Real-World Analogy

Think of useReducer like a restaurant kitchen:

Restaurant useReducer
Customer (You) Your component
Order (Menu Item) Action (describes what should happen)
Waiter Dispatch function
Chef & Kitchen Reducer function
Prepared Food New state

You (the component) don't go into the kitchen and cook the food yourself (directly modify state). Instead, you give an order to the waiter (dispatch an action), who takes it to the kitchen (reducer), where the chef (reducer logic) prepares your meal (calculates new state) following a recipe (reducer function logic). The result comes back to you (updated state triggers re-render).

⚡ Interactive useReducer Cycle

Click "Dispatch Action" to see how useReducer works step by step

💻 Component Uses state & dispatch ⚙️ Reducer (state, action) → newState 📦 State count: 0 dispatch(action) returns new state triggers re-render +1 ▶ Dispatch INCREMENT Click the button to see the flow

👆 Click "Dispatch INCREMENT" to see how useReducer processes an action and updates state.

💡 Key Concept

The fundamental idea behind useReducer is separation of concerns: your component describes what should happen (by dispatching actions), while the reducer specifies how the state should change. This makes your code more maintainable and easier to reason about.

The useReducer Signature

Here's what useReducer looks like at a glance:

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

Let's break down each part:

Part Description
state The current state value (like useState)
dispatch Function to trigger state updates by sending actions
reducer Function that takes (state, action) and returns new state
initialState The starting state value

🔄 The Reducer Pattern

Before diving into useReducer specifically, let's understand the reducer pattern itself. This pattern comes from functional programming and is used in many contexts, including Redux, Array.reduce(), and now React Hooks.

📖 Definition

Reducer: A pure function that takes the current state and an action, then returns a new state. The name "reducer" comes from Array.reduce() - it "reduces" a collection of actions over time into a single state value.

Anatomy of a Reducer

Every reducer follows this basic structure:

function reducer(state, action) {
    // Examine the action
    switch (action.type) {
        case 'ACTION_TYPE_1':
            // Return new state based on this action
            return { ...state, /* changes */ };
        
        case 'ACTION_TYPE_2':
            // Return new state for different action
            return { ...state, /* changes */ };
        
        default:
            // Always return current state for unknown actions
            return state;
    }
}

✅ Reducer Rules

Reducers must follow these important rules:

  1. Pure Function - Same inputs always produce same output, no side effects
  2. No Mutations - Never modify state directly; always return a new state object
  3. Synchronous - No async operations, promises, or API calls inside reducers
  4. Return State - Always return a state value (even if unchanged)

Understanding Actions

Actions are plain JavaScript objects that describe what happened. By convention, actions have a type property that identifies the action, and often include additional data:

// Simple action with just a type
{ type: 'INCREMENT' }

// Action with additional data (payload)
{ type: 'ADD_TODO', payload: { id: 1, text: 'Learn useReducer' } }

// Action with multiple properties
{ 
    type: 'UPDATE_USER',
    id: 123,
    name: 'John Doe',
    email: 'john@example.com'
}

💡 Naming Convention

Action types are typically written in SCREAMING_SNAKE_CASE (all uppercase with underscores). This makes them stand out in your code and clearly indicates they're constants.

The Reducer Flow

Here's how data flows through a reducer:

🔄 Interactive Reducer Flow

Click on each step to see how the reducer pattern processes actions

Current State { count: 5 } 1 Action { type: 'INC' } 2 Reducer switch (action.type) return state + 1 3 New State 6 4 reducer(state, action) → newState Click each step to learn more

👆 Click on any step (1-4) to understand how the reducer pattern works.

Example: Array.reduce() vs Reducer Pattern

To understand why it's called a "reducer," let's see how Array.reduce() works similarly:

// Array.reduce() example
const numbers = [1, 2, 3, 4, 5];

const sum = numbers.reduce(
    (accumulator, currentValue) => accumulator + currentValue,
    0  // initial value
);
// Result: 15

// This is conceptually similar to:
// state = 0 (initial)
// action = 1 → state = 0 + 1 = 1
// action = 2 → state = 1 + 2 = 3
// action = 3 → state = 3 + 3 = 6
// action = 4 → state = 6 + 4 = 10
// action = 5 → state = 10 + 5 = 15

// useReducer does the same thing, but with actions over time!
const counterReducer = (state, action) => {
    if (action.type === 'INCREMENT') {
        return state + 1;
    }
    return state;
};

// dispatch({ type: 'INCREMENT' })  → state goes from 0 to 1
// dispatch({ type: 'INCREMENT' })  → state goes from 1 to 2
// dispatch({ type: 'INCREMENT' })  → state goes from 2 to 3

⚠️ Important Insight

Both Array.reduce() and useReducer "reduce" a collection of values into a single result. Array.reduce() processes an array of values immediately, while useReducer processes actions over time as they're dispatched. The pattern is the same!

⚖️ useState vs useReducer

Both useState and useReducer manage state, but they're suited for different scenarios. Understanding when to use each is key to writing clean, maintainable React code.

When to Use useState

✅ Use useState When:

  • State is simple (single primitive value or simple object)
  • State updates are independent of each other
  • Few state variables (typically 1-3)
  • State update logic is straightforward
  • No complex relationships between state values

Example Scenarios:

  • Toggle state (boolean)
  • Form input value (string)
  • Counter (number)
  • Single selection (string or number)

When to Use useReducer

💡 Use useReducer When:

  • State is complex (multiple related sub-values)
  • Next state depends on previous state in complex ways
  • Many state updates from different actions
  • State update logic is getting complicated
  • You want to separate state logic from component logic
  • Testing state logic independently is important

Example Scenarios:

  • Todo list with add, remove, toggle, filter operations
  • Shopping cart with add, remove, update quantity, apply coupon
  • Form with multiple fields and validation rules
  • Multi-step wizard with navigation and data
  • Complex UI state (modals, tabs, accordions)

Side-by-Side Comparison

Let's see the same counter implemented with both hooks:

With useState

import { useState } from 'react';

function Counter() {
    const [count, setCount] = useState(0);

    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => setCount(count + 1)}>
                Increment
            </button>
            <button onClick={() => setCount(count - 1)}>
                Decrement
            </button>
            <button onClick={() => setCount(0)}>
                Reset
            </button>
        </div>
    );
}

With useReducer

import { useReducer } from 'react';

// Reducer function (lives outside component)
function counterReducer(state: number, action: { type: string }) {
    switch (action.type) {
        case 'INCREMENT':
            return state + 1;
        case 'DECREMENT':
            return state - 1;
        case 'RESET':
            return 0;
        default:
            return state;
    }
}

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

    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => dispatch({ type: 'INCREMENT' })}>
                Increment
            </button>
            <button onClick={() => dispatch({ type: 'DECREMENT' })}>
                Decrement
            </button>
            <button onClick={() => dispatch({ type: 'RESET' })}>
                Reset
            </button>
        </div>
    );
}

For this simple counter, useState is actually better - less code, more straightforward. But let's see what happens when we add more complexity:

Complex Counter with useState

function ComplexCounter() {
    const [count, setCount] = useState(0);
    const [step, setStep] = useState(1);
    const [history, setHistory] = useState<number[]>([0]);

    const increment = () => {
        const newCount = count + step;
        setCount(newCount);
        setHistory([...history, newCount]);
    };

    const decrement = () => {
        const newCount = count - step;
        setCount(newCount);
        setHistory([...history, newCount]);
    };

    const reset = () => {
        setCount(0);
        setHistory([0]);
    };

    const undo = () => {
        if (history.length > 1) {
            const newHistory = history.slice(0, -1);
            setHistory(newHistory);
            setCount(newHistory[newHistory.length - 1]);
        }
    };

    // This is getting messy! Three related state values,
    // and we have to keep them in sync manually.
}

Complex Counter with useReducer

interface CounterState {
    count: number;
    step: number;
    history: number[];
}

type CounterAction =
    | { type: 'INCREMENT' }
    | { type: 'DECREMENT' }
    | { type: 'RESET' }
    | { type: 'UNDO' }
    | { type: 'SET_STEP'; payload: number };

function counterReducer(state: CounterState, action: CounterAction): CounterState {
    switch (action.type) {
        case 'INCREMENT': {
            const newCount = state.count + state.step;
            return {
                ...state,
                count: newCount,
                history: [...state.history, newCount]
            };
        }
        case 'DECREMENT': {
            const newCount = state.count - state.step;
            return {
                ...state,
                count: newCount,
                history: [...state.history, newCount]
            };
        }
        case 'RESET':
            return {
                ...state,
                count: 0,
                history: [0]
            };
        case 'UNDO':
            if (state.history.length > 1) {
                const newHistory = state.history.slice(0, -1);
                return {
                    ...state,
                    count: newHistory[newHistory.length - 1],
                    history: newHistory
                };
            }
            return state;
        case 'SET_STEP':
            return {
                ...state,
                step: action.payload
            };
        default:
            return state;
    }
}

function ComplexCounter() {
    const [state, dispatch] = useReducer(counterReducer, {
        count: 0,
        step: 1,
        history: [0]
    });

    // Much cleaner component code!
    // All the complex logic is in the reducer.
}

✅ Key Takeaway

As your state becomes more complex, useReducer helps you:

  • Keep related state values together
  • Ensure state updates are consistent
  • Make the component code cleaner
  • Test state logic independently
  • Scale without the mess

Decision Tree

Use this decision tree to choose between useState and useReducer:

graph TD A[Need to manage state?] --> B{State is simple?} B -->|Yes| C{1-2 state values?} B -->|No| D[Use useReducer] C -->|Yes| E{Independent updates?} C -->|No| D E -->|Yes| F[Use useState] E -->|No| D style F fill:#48bb78,stroke:#333,stroke-width:2px,color:#fff style D fill:#667eea,stroke:#333,stroke-width:2px,color:#fff

📊 Interactive: Watch Complexity Grow

Click "Add Feature" to see how useState becomes harder to manage as complexity increases

Code Complexity High Low Number of Features 1 2 3 4 5 6 useState useReducer Features Added 1 Switch Point! + Add Feature Reset
useState Approach:
  • const [count, setCount] = useState(0)
useReducer Approach:
  • dispatch({ type: 'INCREMENT' })
💡 Insight: With just 1 feature, both approaches are similar. Click "Add Feature" to see how they diverge!

📝 Basic useReducer Syntax

Now that you understand the concepts, let's dive into the actual syntax of useReducer. We'll start simple and build up complexity gradually.

The Basic Pattern

Here's the fundamental structure of using useReducer:

import { useReducer } from 'react';

// Step 1: Define your reducer function
function reducer(state, action) {
    switch (action.type) {
        case 'ACTION_NAME':
            return newState;
        default:
            return state;
    }
}

// Step 2: Use it in your component
function MyComponent() {
    const [state, dispatch] = useReducer(reducer, initialState);
    
    // Step 3: Dispatch actions to update state
    const handleClick = () => {
        dispatch({ type: 'ACTION_NAME' });
    };
    
    return (
        <div>
            {/* Use state and dispatch in your JSX */}
        </div>
    );
}

Breaking Down the Parts

Let's examine each piece in detail:

1. The Reducer Function

function reducer(state, action) {
    // state: current state value
    // action: object describing what happened
    
    // Return the new state based on the action
    return newState;
}

The reducer function:

  • Takes two parameters: current state and an action
  • Returns the new state (or current state if action is unrecognized)
  • Is typically defined outside the component (so it's not recreated on every render)
  • Should be a pure function (no side effects)

2. The useReducer Call

const [state, dispatch] = useReducer(reducer, initialState);
Parameter Description Example
reducer The reducer function counterReducer
initialState Starting state value 0 or { count: 0 }
state Current state (returned) Use like {state.count}
dispatch Function to trigger updates (returned) dispatch({ type: 'INCREMENT' })

3. Dispatching Actions

// Simple action
dispatch({ type: 'INCREMENT' });

// Action with data
dispatch({ type: 'SET_NAME', payload: 'John' });

// Action with multiple properties
dispatch({
    type: 'UPDATE_USER',
    id: 123,
    name: 'John Doe'
});

💡 Understanding Dispatch

The dispatch function is stable - it doesn't change between renders. This makes it safe to pass to child components or include in dependency arrays. React guarantees the same dispatch function will be used throughout the component's lifetime.

Lazy Initialization (Optional)

Sometimes calculating the initial state is expensive. You can pass a third parameter - an init function:

// Expensive initial state calculation
function init(initialCount: number) {
    // This only runs once on mount
    return {
        count: initialCount,
        history: [initialCount],
        timestamp: Date.now()
    };
}

// Use it like this:
const [state, dispatch] = useReducer(reducer, 10, init);

// Now 'init' is called with 10 as argument
// Result: { count: 10, history: [10], timestamp: 1234567890 }

⚠️ When to Use Lazy Initialization

Use the init function when:

  • Initial state calculation is expensive (reading from localStorage, complex computations)
  • Initial state depends on props (but you want to compute it only once)
  • You want to extract and reuse the initialization logic

Don't use it for simple initial states - it adds unnecessary complexity.

Complete Minimal Example

Here's a complete working example to see everything together:

import { useReducer } from 'react';

// Reducer function
function counterReducer(state: number, action: { type: string }) {
    switch (action.type) {
        case 'INCREMENT':
            return state + 1;
        case 'DECREMENT':
            return state - 1;
        default:
            return state;
    }
}

// Component
function Counter() {
    // Hook call
    const [count, dispatch] = useReducer(counterReducer, 0);
    
    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => dispatch({ type: 'INCREMENT' })}>
                +1
            </button>
            <button onClick={() => dispatch({ type: 'DECREMENT' })}>
                -1
            </button>
        </div>
    );
}

export default Counter;

✅ What's Happening Here

  1. Component mounts with initial state 0
  2. User clicks "+1" button
  3. dispatch({ type: 'INCREMENT' }) is called
  4. Reducer receives (0, { type: 'INCREMENT' })
  5. Reducer returns 1
  6. React updates state to 1 and re-renders
  7. Component shows "Count: 1"

🔷 Typing Reducers with TypeScript

TypeScript makes useReducer even more powerful by ensuring type safety for your state and actions. Let's learn how to properly type everything for maximum safety and great developer experience.

Why Type Your Reducers?

✅ Benefits of Typed Reducers

  • Catch Errors Early - TypeScript will warn you about invalid action types
  • Autocomplete - Your editor suggests valid action types and payloads
  • Refactoring Safety - Rename actions and TypeScript finds all usages
  • Documentation - Types serve as documentation for your state shape
  • Confidence - Know your reducer handles all cases correctly

Basic Typing Pattern

Here's the fundamental pattern for typing reducers:

// 1. Define your state type
interface State {
    // your state properties
}

// 2. Define your action types
type Action = 
    | { type: 'ACTION_ONE' }
    | { type: 'ACTION_TWO', payload: string }
    | { type: 'ACTION_THREE', payload: number };

// 3. Type your reducer
function reducer(state: State, action: Action): State {
    switch (action.type) {
        case 'ACTION_ONE':
            return { ...state };
        case 'ACTION_TWO':
            // TypeScript knows action.payload is a string here!
            return { ...state, value: action.payload };
        case 'ACTION_THREE':
            // TypeScript knows action.payload is a number here!
            return { ...state, count: action.payload };
        default:
            return state;
    }
}

// 4. Use with typed state and dispatch
const [state, dispatch] = useReducer(reducer, initialState);

Step-by-Step Example: Typed Counter

Let's build a fully-typed counter with multiple operations:

Step 1: Define State Type

interface CounterState {
    count: number;
    step: number;
}

Step 2: Define Action Types

// Using discriminated union types
type CounterAction =
    | { type: 'INCREMENT' }
    | { type: 'DECREMENT' }
    | { type: 'RESET' }
    | { type: 'SET_STEP'; step: number }
    | { type: 'SET_COUNT'; count: number };

// TypeScript now knows all possible actions!

💡 Discriminated Unions

The pipe (|) operator creates a union type - the action can be any one of these types. TypeScript uses the type property to discriminate between them, giving you perfect autocomplete and type checking!

Step 3: Type the Reducer

function counterReducer(
    state: CounterState,
    action: CounterAction
): CounterState {
    switch (action.type) {
        case 'INCREMENT':
            return {
                ...state,
                count: state.count + state.step
            };
        
        case 'DECREMENT':
            return {
                ...state,
                count: state.count - state.step
            };
        
        case 'RESET':
            return {
                ...state,
                count: 0
            };
        
        case 'SET_STEP':
            // TypeScript knows action.step exists and is a number!
            return {
                ...state,
                step: action.step
            };
        
        case 'SET_COUNT':
            // TypeScript knows action.count exists and is a number!
            return {
                ...state,
                count: action.count
            };
        
        default:
            // This ensures we handle all action types
            const exhaustiveCheck: never = action;
            return state;
    }
}

⚠️ The Exhaustive Check Pattern

The const exhaustiveCheck: never = action; line is a clever TypeScript pattern. If you forget to handle an action type, TypeScript will error because action won't be never. This ensures you handle all cases!

Step 4: Use in Component

import { useReducer } from 'react';

function Counter() {
    const [state, dispatch] = useReducer(counterReducer, {
        count: 0,
        step: 1
    });

    return (
        <div>
            <p>Count: {state.count}</p>
            <p>Step: {state.step}</p>
            
            <button onClick={() => dispatch({ type: 'INCREMENT' })}>
                + {state.step}
            </button>
            
            <button onClick={() => dispatch({ type: 'DECREMENT' })}>
                - {state.step}
            </button>
            
            <button onClick={() => dispatch({ type: 'RESET' })}>
                Reset
            </button>
            
            <input
                type="number"
                value={state.step}
                onChange={(e) => dispatch({
                    type: 'SET_STEP',
                    step: Number(e.target.value)
                })}
            />
            
            {/* TypeScript will error if you dispatch invalid action! */}
            {/* dispatch({ type: 'INVALID' }) // ❌ Error! */}
            {/* dispatch({ type: 'SET_STEP' }) // ❌ Error! Missing 'step' */}
        </div>
    );
}

Advanced Typing Patterns

Pattern 1: Using Constants for Action Types

// Define action type constants
const INCREMENT = 'INCREMENT' as const;
const DECREMENT = 'DECREMENT' as const;
const SET_COUNT = 'SET_COUNT' as const;

// Use them in your action types
type CounterAction =
    | { type: typeof INCREMENT }
    | { type: typeof DECREMENT }
    | { type: typeof SET_COUNT; count: number };

// Benefits: Refactor-safe, reusable, no magic strings
dispatch({ type: INCREMENT }); // Works!
dispatch({ type: 'INCREMENT' }); // Also works!

Pattern 2: Payload Convention

// Many developers use 'payload' for action data
type TodoAction =
    | { type: 'ADD_TODO'; payload: { text: string } }
    | { type: 'TOGGLE_TODO'; payload: { id: number } }
    | { type: 'DELETE_TODO'; payload: { id: number } };

// Usage
dispatch({
    type: 'ADD_TODO',
    payload: { text: 'Learn useReducer' }
});

Pattern 3: Generic Reducer Type

// Create a generic reducer type
type Reducer<S, A> = (state: S, action: A) => S;

// Use it to type your reducer
const counterReducer: Reducer<CounterState, CounterAction> = (state, action) => {
    // TypeScript infers parameter types!
    switch (action.type) {
        // ...
    }
};

Type Inference with useReducer

TypeScript can infer types from your reducer, but explicit typing is often better:

// TypeScript infers types from reducer
const [state, dispatch] = useReducer(counterReducer, { count: 0, step: 1 });
// state is inferred as CounterState
// dispatch is inferred as Dispatch<CounterAction>

// You can also be explicit (recommended for clarity)
const [state, dispatch] = useReducer<CounterState, CounterAction>(
    counterReducer,
    { count: 0, step: 1 }
);

✅ TypeScript Typing Best Practices

  • Always type your state interface/type
  • Use discriminated unions for actions
  • Include the exhaustive check in default case
  • Use as const for action type constants
  • Consider using payload convention for consistency
  • Let TypeScript infer types where it's clear

🎬 Actions and Action Types

Actions are the messages you send to your reducer to describe what should happen. Understanding how to structure actions effectively is key to writing clean, maintainable code with useReducer.

Anatomy of an Action

An action is just a plain JavaScript object, but it follows certain conventions:

// Minimal action - just a type
{ type: 'RESET' }

// Action with data
{
    type: 'ADD_TODO',
    payload: {
        id: 1,
        text: 'Learn useReducer',
        completed: false
    }
}

// Action with multiple properties (alternative to payload)
{
    type: 'UPDATE_USER',
    id: 123,
    name: 'John Doe',
    email: 'john@example.com'
}

📖 Action Structure

Required:

  • type - A string describing the action (REQUIRED)

Optional:

  • payload - Data needed for the action (COMMON)
  • Other properties - Additional data (LESS COMMON)

Naming Action Types

Good action type names make your code self-documenting. Follow these conventions:

Convention Example Why?
SCREAMING_SNAKE_CASE ADD_TODO Standard in Redux, stands out as constant
Verb + Noun FETCH_USER Describes the action clearly
Past tense events USER_LOADED For completed actions/events
Domain prefixes TODO_ADDED Organizes actions by feature
// ✅ Good action type names
'ADD_TODO'           // Clear action
'TOGGLE_TODO'        // Specific operation
'DELETE_TODO'        // No ambiguity
'FILTER_TODOS'       // Describes intent
'SET_LOADING'        // Clear state change

// ❌ Poor action type names
'TODO'               // What about todo?
'UPDATE'             // Update what?
'CHANGE'             // Too vague
'CLICK'              // Describes UI, not intent
'handleClick'        // Not a constant name

Action Creator Functions

Instead of manually creating action objects everywhere, use action creator functions:

// Without action creators (not recommended)
dispatch({ type: 'ADD_TODO', payload: { id: 1, text: 'Learn', completed: false } });
dispatch({ type: 'ADD_TODO', payload: { id: 2, text: 'Practice', completed: false } });

// With action creators (recommended!)
function addTodo(id: number, text: string) {
    return {
        type: 'ADD_TODO' as const,
        payload: {
            id,
            text,
            completed: false
        }
    };
}

// Usage - much cleaner!
dispatch(addTodo(1, 'Learn'));
dispatch(addTodo(2, 'Practice'));

✅ Benefits of Action Creators

  • Consistency - All actions have the same structure
  • Reusability - One function, many uses
  • Type Safety - TypeScript checks parameters
  • Testability - Easy to test action creation
  • Refactoring - Change structure in one place
  • Documentation - Function signature documents required data

Complete Action Creators Example

// Action types
type TodoAction =
    | ReturnType<typeof addTodo>
    | ReturnType<typeof toggleTodo>
    | ReturnType<typeof deleteTodo>
    | ReturnType<typeof updateTodoText>;

// Action creators
function addTodo(text: string) {
    return {
        type: 'ADD_TODO' as const,
        payload: {
            id: Date.now(),
            text,
            completed: false
        }
    };
}

function toggleTodo(id: number) {
    return {
        type: 'TOGGLE_TODO' as const,
        payload: { id }
    };
}

function deleteTodo(id: number) {
    return {
        type: 'DELETE_TODO' as const,
        payload: { id }
    };
}

function updateTodoText(id: number, text: string) {
    return {
        type: 'UPDATE_TODO_TEXT' as const,
        payload: { id, text }
    };
}

// Usage in component
function TodoList() {
    const [state, dispatch] = useReducer(todoReducer, initialState);

    const handleAdd = (text: string) => {
        dispatch(addTodo(text));
    };

    const handleToggle = (id: number) => {
        dispatch(toggleTodo(id));
    };

    // TypeScript ensures you pass correct parameters!
    // dispatch(addTodo()); // ❌ Error: missing text parameter
    // dispatch(toggleTodo('hello')); // ❌ Error: id must be number
}

💡 ReturnType Utility

The ReturnType<typeof function> utility type extracts the return type of a function. This lets TypeScript infer your action types from your action creators automatically - no duplicate type definitions!

Action Patterns

Pattern 1: Simple Actions (No Data)

// For actions that don't need data
const reset = () => ({ type: 'RESET' as const });
const clearAll = () => ({ type: 'CLEAR_ALL' as const });

dispatch(reset());
dispatch(clearAll());

Pattern 2: Payload Actions

// For actions with a single piece of data
const setName = (name: string) => ({
    type: 'SET_NAME' as const,
    payload: name
});

const setAge = (age: number) => ({
    type: 'SET_AGE' as const,
    payload: age
});

dispatch(setName('John'));
dispatch(setAge(30));

Pattern 3: Object Payload Actions

// For actions with complex data
const updateUser = (updates: Partial<User>) => ({
    type: 'UPDATE_USER' as const,
    payload: updates
});

dispatch(updateUser({
    name: 'John Doe',
    email: 'john@example.com'
}));

Pattern 4: Multiple Property Actions

// Alternative to payload (less common but valid)
const moveItem = (from: number, to: number) => ({
    type: 'MOVE_ITEM' as const,
    from,
    to
});

dispatch(moveItem(0, 3));

⚠️ Action Best Practices

  • Keep actions simple - they describe what happened, not how to handle it
  • Use action creators for consistency
  • Name actions descriptively (verb + noun)
  • Keep payload data minimal - only what's needed
  • Don't put functions or promises in actions
  • Group related actions with prefixes (TODO_ADD, TODO_DELETE)

🔢 Simple Counter Example

Let's put everything we've learned together by building a complete counter application with useReducer. We'll start simple and gradually add features to see how useReducer scales.

Basic Counter

Here's a simple counter with increment and decrement:

import { useReducer } from 'react';

// State type
type CounterState = number;

// Action types
type CounterAction =
    | { type: 'INCREMENT' }
    | { type: 'DECREMENT' }
    | { type: 'RESET' };

// Reducer function
function counterReducer(state: CounterState, action: CounterAction): CounterState {
    switch (action.type) {
        case 'INCREMENT':
            return state + 1;
        case 'DECREMENT':
            return state - 1;
        case 'RESET':
            return 0;
        default:
            return state;
    }
}

// Component
function Counter() {
    const [count, dispatch] = useReducer(counterReducer, 0);

    return (
        <div className="counter">
            <h2>Counter</h2>
            <p className="count-display">{count}</p>
            
            <div className="button-group">
                <button onClick={() => dispatch({ type: 'DECREMENT' })}>
                    -1
                </button>
                <button onClick={() => dispatch({ type: 'RESET' })}>
                    Reset
                </button>
                <button onClick={() => dispatch({ type: 'INCREMENT' })}>
                    +1
                </button>
            </div>
        </div>
    );
}

export default Counter;

✅ What We Have

  • Type-safe state and actions
  • Clear separation: reducer handles logic, component handles UI
  • Three actions: INCREMENT, DECREMENT, RESET
  • Simple initial state (0)

Enhanced Counter with Step

Let's add the ability to change the increment/decrement step:

import { useReducer } from 'react';

// State type - now an object with count and step
interface CounterState {
    count: number;
    step: number;
}

// Action types - added SET_STEP
type CounterAction =
    | { type: 'INCREMENT' }
    | { type: 'DECREMENT' }
    | { type: 'RESET' }
    | { type: 'SET_STEP'; payload: number };

// Reducer - handles multiple pieces of state
function counterReducer(state: CounterState, action: CounterAction): CounterState {
    switch (action.type) {
        case 'INCREMENT':
            return {
                ...state,
                count: state.count + state.step
            };
        
        case 'DECREMENT':
            return {
                ...state,
                count: state.count - state.step
            };
        
        case 'RESET':
            return {
                ...state,
                count: 0
            };
        
        case 'SET_STEP':
            return {
                ...state,
                step: action.payload
            };
        
        default:
            return state;
    }
}

// Component
function Counter() {
    const [state, dispatch] = useReducer(counterReducer, {
        count: 0,
        step: 1
    });

    return (
        <div className="counter">
            <h2>Enhanced Counter</h2>
            <p className="count-display">Count: {state.count}</p>
            <p className="step-display">Step: {state.step}</p>
            
            <div className="button-group">
                <button onClick={() => dispatch({ type: 'DECREMENT' })}>
                    -{state.step}
                </button>
                <button onClick={() => dispatch({ type: 'RESET' })}>
                    Reset
                </button>
                <button onClick={() => dispatch({ type: 'INCREMENT' })}>
                    +{state.step}
                </button>
            </div>

            <div className="step-control">
                <label>
                    Change Step:
                    <input
                        type="number"
                        value={state.step}
                        onChange={(e) => dispatch({
                            type: 'SET_STEP',
                            payload: Number(e.target.value)
                        })}
                        min="1"
                    />
                </label>
            </div>
        </div>
    );
}

export default Counter;

💡 Key Improvements

  • Object State - Now tracking two related values (count and step)
  • Spread Operator - Using ...state to preserve other state values
  • Dynamic Step - Increment/decrement by user-defined amount
  • Single Reducer - All state logic in one place

Advanced Counter with History

Now let's add undo functionality by tracking history:

import { useReducer } from 'react';

// State type with history
interface CounterState {
    count: number;
    step: number;
    history: number[];
}

// Action types with undo
type CounterAction =
    | { type: 'INCREMENT' }
    | { type: 'DECREMENT' }
    | { type: 'RESET' }
    | { type: 'SET_STEP'; payload: number }
    | { type: 'UNDO' };

// Reducer with history management
function counterReducer(state: CounterState, action: CounterAction): CounterState {
    switch (action.type) {
        case 'INCREMENT': {
            const newCount = state.count + state.step;
            return {
                ...state,
                count: newCount,
                history: [...state.history, newCount]
            };
        }
        
        case 'DECREMENT': {
            const newCount = state.count - state.step;
            return {
                ...state,
                count: newCount,
                history: [...state.history, newCount]
            };
        }
        
        case 'RESET':
            return {
                ...state,
                count: 0,
                history: [0]
            };
        
        case 'SET_STEP':
            return {
                ...state,
                step: action.payload
            };
        
        case 'UNDO':
            // Can't undo if only initial value in history
            if (state.history.length <= 1) {
                return state;
            }
            
            // Remove last history entry
            const newHistory = state.history.slice(0, -1);
            // Get the previous count value
            const previousCount = newHistory[newHistory.length - 1];
            
            return {
                ...state,
                count: previousCount,
                history: newHistory
            };
        
        default:
            return state;
    }
}

// Component
function Counter() {
    const [state, dispatch] = useReducer(counterReducer, {
        count: 0,
        step: 1,
        history: [0]
    });

    const canUndo = state.history.length > 1;

    return (
        <div className="counter">
            <h2>Counter with History</h2>
            <p className="count-display">Count: {state.count}</p>
            <p className="step-display">Step: {state.step}</p>
            
            <div className="button-group">
                <button onClick={() => dispatch({ type: 'DECREMENT' })}>
                    -{state.step}
                </button>
                <button onClick={() => dispatch({ type: 'RESET' })}>
                    Reset
                </button>
                <button onClick={() => dispatch({ type: 'INCREMENT' })}>
                    +{state.step}
                </button>
                <button 
                    onClick={() => dispatch({ type: 'UNDO' })}
                    disabled={!canUndo}
                >
                    ↶ Undo
                </button>
            </div>

            <div className="step-control">
                <label>
                    Change Step:
                    <input
                        type="number"
                        value={state.step}
                        onChange={(e) => dispatch({
                            type: 'SET_STEP',
                            payload: Number(e.target.value)
                        })}
                        min="1"
                    />
                </label>
            </div>

            <div className="history">
                <h3>History</h3>
                <p>{state.history.join(' → ')}</p>
            </div>
        </div>
    );
}

export default Counter;

✅ Advanced Features

  • History Tracking - Array of all previous counts
  • Undo Capability - Go back to previous state
  • UI State - Disable undo when no history
  • Consistent Updates - History updates with every change

This would be very messy with multiple useState calls! useReducer keeps everything organized.

⚠️ Notice the Pattern

See how we use curly braces { } in some case blocks? This creates a block scope, allowing us to declare variables like newCount without conflicts between cases. Without the braces, you'd get "variable already declared" errors!

📝 Complex State Example: Todo List

Now let's tackle a real-world example that truly showcases useReducer's power: a todo list with multiple features. This example will demonstrate why useReducer is superior to useState for complex state.

Todo List Requirements

Features We'll Build

  • ➕ Add new todos
  • ✓ Toggle todo completion
  • 🗑️ Delete todos
  • ✏️ Edit todo text
  • 🔽 Filter todos (all, active, completed)
  • 🧹 Clear completed todos

Step 1: Define Types

// Todo item type
interface Todo {
    id: number;
    text: string;
    completed: boolean;
}

// Filter type
type FilterType = 'all' | 'active' | 'completed';

// State type
interface TodoState {
    todos: Todo[];
    filter: FilterType;
}

// Action types
type TodoAction =
    | { type: 'ADD_TODO'; payload: { text: string } }
    | { type: 'TOGGLE_TODO'; payload: { id: number } }
    | { type: 'DELETE_TODO'; payload: { id: number } }
    | { type: 'EDIT_TODO'; payload: { id: number; text: string } }
    | { type: 'SET_FILTER'; payload: { filter: FilterType } }
    | { type: 'CLEAR_COMPLETED' };

Step 2: Create Action Creators

// Action creators
const addTodo = (text: string) => ({
    type: 'ADD_TODO' as const,
    payload: { text }
});

const toggleTodo = (id: number) => ({
    type: 'TOGGLE_TODO' as const,
    payload: { id }
});

const deleteTodo = (id: number) => ({
    type: 'DELETE_TODO' as const,
    payload: { id }
});

const editTodo = (id: number, text: string) => ({
    type: 'EDIT_TODO' as const,
    payload: { id, text }
});

const setFilter = (filter: FilterType) => ({
    type: 'SET_FILTER' as const,
    payload: { filter }
});

const clearCompleted = () => ({
    type: 'CLEAR_COMPLETED' as const
});

Step 3: Implement Reducer

function todoReducer(state: TodoState, action: TodoAction): TodoState {
    switch (action.type) {
        case 'ADD_TODO':
            return {
                ...state,
                todos: [
                    ...state.todos,
                    {
                        id: Date.now(),
                        text: action.payload.text,
                        completed: false
                    }
                ]
            };
        
        case 'TOGGLE_TODO':
            return {
                ...state,
                todos: state.todos.map(todo =>
                    todo.id === action.payload.id
                        ? { ...todo, completed: !todo.completed }
                        : todo
                )
            };
        
        case 'DELETE_TODO':
            return {
                ...state,
                todos: state.todos.filter(todo => todo.id !== action.payload.id)
            };
        
        case 'EDIT_TODO':
            return {
                ...state,
                todos: state.todos.map(todo =>
                    todo.id === action.payload.id
                        ? { ...todo, text: action.payload.text }
                        : todo
                )
            };
        
        case 'SET_FILTER':
            return {
                ...state,
                filter: action.payload.filter
            };
        
        case 'CLEAR_COMPLETED':
            return {
                ...state,
                todos: state.todos.filter(todo => !todo.completed)
            };
        
        default:
            return state;
    }
}

💡 Reducer Patterns Used

  • Immutable Updates - Using spread operator and array methods
  • Array Mapping - Update specific items without mutation
  • Array Filtering - Remove items by creating new array
  • Conditional Updates - Only update matching items

📝 Interactive Todo State Explorer

Click actions to see how the reducer transforms state immutably

📦 Current State todos: [ { id: 1, text: "Learn React", completed: false } { id: 2, text: "Master Hooks", completed: false } ] filter: "all" 🎯 Dispatch an Action: TOGGLE_TODO (id: 1) ADD_TODO ("Build App") DELETE_TODO (id: 2) SET_FILTER ("active") ⚙️ reducer(state, action) ✨ New State (Immutable) todos: [ -- Click an action -- ] filter: "all" 💬 Action Dispatched Click an action button to see how the reducer processes it ✅ Changes Made: Reset
🔑 Key Concept: Notice how the reducer never modifies the original state. It always creates and returns a NEW state object. This immutability is essential for React to detect changes!

Step 4: Build Component

import { useReducer, useState } from 'react';

function TodoList() {
    const [state, dispatch] = useReducer(todoReducer, {
        todos: [],
        filter: 'all'
    });

    const [inputValue, setInputValue] = useState('');

    // Filter todos based on current filter
    const filteredTodos = state.todos.filter(todo => {
        if (state.filter === 'active') return !todo.completed;
        if (state.filter === 'completed') return todo.completed;
        return true; // 'all'
    });

    // Handle add todo
    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        if (inputValue.trim()) {
            dispatch(addTodo(inputValue));
            setInputValue('');
        }
    };

    // Count active todos
    const activeTodoCount = state.todos.filter(t => !t.completed).length;

    return (
        <div className="todo-app">
            <h1>📝 Todo List</h1>

            {/* Add Todo Form */}
            <form onSubmit={handleSubmit}>
                <input
                    type="text"
                    value={inputValue}
                    onChange={(e) => setInputValue(e.target.value)}
                    placeholder="What needs to be done?"
                />
                <button type="submit">Add</button>
            </form>

            {/* Filter Buttons */}
            <div className="filters">
                <button
                    onClick={() => dispatch(setFilter('all'))}
                    className={state.filter === 'all' ? 'active' : ''}
                >
                    All ({state.todos.length})
                </button>
                <button
                    onClick={() => dispatch(setFilter('active'))}
                    className={state.filter === 'active' ? 'active' : ''}
                >
                    Active ({activeTodoCount})
                </button>
                <button
                    onClick={() => dispatch(setFilter('completed'))}
                    className={state.filter === 'completed' ? 'active' : ''}
                >
                    Completed ({state.todos.length - activeTodoCount})
                </button>
            </div>

            {/* Todo List */}
            <ul className="todo-list">
                {filteredTodos.map(todo => (
                    <TodoItem
                        key={todo.id}
                        todo={todo}
                        onToggle={() => dispatch(toggleTodo(todo.id))}
                        onDelete={() => dispatch(deleteTodo(todo.id))}
                        onEdit={(text) => dispatch(editTodo(todo.id, text))}
                    />
                ))}
            </ul>

            {/* Footer */}
            {state.todos.length > 0 && (
                <div className="footer">
                    <span>{activeTodoCount} item(s) left</span>
                    {activeTodoCount < state.todos.length && (
                        <button onClick={() => dispatch(clearCompleted())}>
                            Clear Completed
                        </button>
                    )}
                </div>
            )}
        </div>
    );
}

export default TodoList;

Step 5: TodoItem Component

import { useState } from 'react';

interface TodoItemProps {
    todo: Todo;
    onToggle: () => void;
    onDelete: () => void;
    onEdit: (text: string) => void;
}

function TodoItem({ todo, onToggle, onDelete, onEdit }: TodoItemProps) {
    const [isEditing, setIsEditing] = useState(false);
    const [editValue, setEditValue] = useState(todo.text);

    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        if (editValue.trim()) {
            onEdit(editValue);
            setIsEditing(false);
        }
    };

    const handleCancel = () => {
        setEditValue(todo.text);
        setIsEditing(false);
    };

    if (isEditing) {
        return (
            <li className="todo-item editing">
                <form onSubmit={handleSubmit}>
                    <input
                        type="text"
                        value={editValue}
                        onChange={(e) => setEditValue(e.target.value)}
                        autoFocus
                    />
                    <button type="submit">Save</button>
                    <button type="button" onClick={handleCancel}>
                        Cancel
                    </button>
                </form>
            </li>
        );
    }

    return (
        <li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
            <input
                type="checkbox"
                checked={todo.completed}
                onChange={onToggle}
            />
            <span 
                onDoubleClick={() => setIsEditing(true)}
                style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
            >
                {todo.text}
            </span>
            <button onClick={onDelete}>Delete</button>
        </li>
    );
}

export default TodoItem;

✅ What We Achieved

This todo app demonstrates:

  • Complex State Management - Todos array + filter state
  • Multiple Operations - 6 different actions
  • Derived State - Filtered todos, counts
  • Component Composition - TodoItem separated for clarity
  • Type Safety - Full TypeScript coverage
  • Clean Code - Component focuses on UI, reducer handles logic

Comparing useState vs useReducer

Imagine implementing this todo app with useState:

// With useState - messy and error-prone!
function TodoList() {
    const [todos, setTodos] = useState<Todo[]>([]);
    const [filter, setFilter] = useState<FilterType>('all');

    const addTodo = (text: string) => {
        setTodos([...todos, {
            id: Date.now(),
            text,
            completed: false
        }]);
    };

    const toggleTodo = (id: number) => {
        setTodos(todos.map(todo =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
        ));
    };

    const deleteTodo = (id: number) => {
        setTodos(todos.filter(todo => todo.id !== id));
    };

    // ... more functions scattered throughout component
    // Logic is mixed with UI code
    // Harder to test
    // More prone to stale closure bugs
}
// With useReducer - clean and organized!
function TodoList() {
    const [state, dispatch] = useReducer(todoReducer, initialState);

    // All logic is in reducer
    // Component focuses on rendering
    // Easy to test reducer independently
    // No stale closure issues
    // Actions clearly describe intent
}

💡 Key Takeaway

For the todo app, useReducer provides:

  • Better Organization - All state logic in one place
  • Easier Testing - Test reducer functions independently
  • Clearer Intent - Action types describe what's happening
  • Fewer Bugs - Centralized logic prevents inconsistencies
  • Scalability - Easy to add new actions

🏋️ Hands-on Practice

Now it's your turn! These exercises will help you master useReducer. Start with the easier ones and work your way up.

Exercise 1: Traffic Light Controller

🎯 Challenge

Build a traffic light controller that cycles through: 🔴 Red → 🟢 Green → 🟡 Yellow → 🔴 Red

Requirements:

  • Use useReducer to manage the current light state
  • Add a "Next" button to cycle to the next light
  • Display the current light with appropriate colors
  • Add a counter showing how many times lights have changed
💡 Hint

Your state might look like:

interface TrafficLightState {
    currentLight: 'red' | 'green' | 'yellow';
    changeCount: number;
}

You'll need actions like: 'NEXT_LIGHT'

✅ Solution
import { useReducer } from 'react';

type LightColor = 'red' | 'green' | 'yellow';

interface TrafficLightState {
    currentLight: LightColor;
    changeCount: number;
}

type TrafficLightAction = { type: 'NEXT_LIGHT' };

function trafficLightReducer(
    state: TrafficLightState,
    action: TrafficLightAction
): TrafficLightState {
    switch (action.type) {
        case 'NEXT_LIGHT': {
            let nextLight: LightColor;
            
            switch (state.currentLight) {
                case 'red':
                    nextLight = 'green';
                    break;
                case 'green':
                    nextLight = 'yellow';
                    break;
                case 'yellow':
                    nextLight = 'red';
                    break;
            }
            
            return {
                currentLight: nextLight,
                changeCount: state.changeCount + 1
            };
        }
        default:
            return state;
    }
}

function TrafficLight() {
    const [state, dispatch] = useReducer(trafficLightReducer, {
        currentLight: 'red',
        changeCount: 0
    });

    const lightColors = {
        red: '#ff0000',
        green: '#00ff00',
        yellow: '#ffff00'
    };

    return (
        <div>
            <h2>🚦 Traffic Light</h2>
            <div
                style={{
                    width: '100px',
                    height: '100px',
                    borderRadius: '50%',
                    backgroundColor: lightColors[state.currentLight],
                    margin: '20px auto'
                }}
            />
            <p>Current: {state.currentLight.toUpperCase()}</p>
            <p>Changes: {state.changeCount}</p>
            <button onClick={() => dispatch({ type: 'NEXT_LIGHT' })}>
                Next Light
            </button>
        </div>
    );
}

Exercise 2: Shopping Cart

🎯 Challenge

Build a shopping cart with add, remove, and quantity update functionality.

Requirements:

  • Add items to cart (with name and price)
  • Remove items from cart
  • Increase/decrease item quantity
  • Display total price
  • Clear entire cart
💡 Hint

Your state might look like:

interface CartItem {
    id: string;
    name: string;
    price: number;
    quantity: number;
}

interface CartState {
    items: CartItem[];
}

Actions: ADD_ITEM, REMOVE_ITEM, UPDATE_QUANTITY, CLEAR_CART

Exercise 3: Form Wizard

🎯 Challenge

Create a multi-step form wizard (personal info → address → confirmation).

Requirements:

  • Three steps with different form fields
  • Next/Previous navigation
  • Form data persists across steps
  • Validation before moving to next step
  • Submit on final step
💡 Hint

Your state might include:

interface WizardState {
    currentStep: number;
    formData: {
        name: string;
        email: string;
        address: string;
        city: string;
        // ... more fields
    };
    errors: Record<string, string>;
}

✅ Practice Tips

  • Start by defining your state and action types
  • Write the reducer before the component
  • Test your reducer with simple console.log calls
  • Add one action at a time and test it
  • Use TypeScript to catch errors early
  • Think about what actions users will trigger

🎨 Common Patterns

Now that you understand useReducer, let's explore common patterns that will help you write better reducer code. These patterns have been refined by the React community and are widely used in production applications.

Pattern 1: Action Creator Pattern

Always use action creator functions instead of creating action objects manually:

// ❌ Not recommended - manual action objects
dispatch({ type: 'ADD_TODO', payload: { text: 'Learn React' } });
dispatch({ type: 'ADD_TODO', payload: { text: 'Build project' } });

// ✅ Recommended - action creators
const addTodo = (text: string) => ({
    type: 'ADD_TODO' as const,
    payload: { text }
});

dispatch(addTodo('Learn React'));
dispatch(addTodo('Build project'));

Benefits: Type safety, consistency, easier refactoring, less repetition

Pattern 2: State Normalization

For collections, use objects instead of arrays for better performance:

// ❌ Array (slower for lookups)
interface State {
    todos: Todo[];
}

// Finding a todo requires iteration
const todo = state.todos.find(t => t.id === 5);

// ✅ Normalized object (O(1) lookups)
interface State {
    todos: {
        byId: Record<number, Todo>;
        allIds: number[];
    };
}

// Direct access - much faster!
const todo = state.todos.byId[5];

When to use: Large collections (100+ items), frequent lookups, performance-critical apps

Pattern 3: Derived State

Don't store computed values in state - calculate them during render:

// ❌ Storing derived state (redundant, can get out of sync)
interface TodoState {
    todos: Todo[];
    completedCount: number;  // Don't do this!
    activeCount: number;     // Don't do this!
}

// ✅ Calculate during render
interface TodoState {
    todos: Todo[];
}

function TodoList() {
    const [state, dispatch] = useReducer(todoReducer, initialState);
    
    // Calculate on each render (React is fast!)
    const completedCount = state.todos.filter(t => t.completed).length;
    const activeCount = state.todos.length - completedCount;
    
    // ...
}

💡 Why This Works

React renders are fast! Computing derived values on each render is usually cheaper than keeping them in sync. If calculations are truly expensive, use useMemo later.

Pattern 4: Reducer Composition

Split large reducers into smaller, focused reducers:

// Split by domain
function todosReducer(todos: Todo[], action: TodoAction): Todo[] {
    switch (action.type) {
        case 'ADD_TODO':
            return [...todos, newTodo];
        case 'TOGGLE_TODO':
            return todos.map(/* ... */);
        default:
            return todos;
    }
}

function filterReducer(filter: FilterType, action: FilterAction): FilterType {
    switch (action.type) {
        case 'SET_FILTER':
            return action.payload;
        default:
            return filter;
    }
}

// Combine them
function appReducer(state: AppState, action: AppAction): AppState {
    return {
        todos: todosReducer(state.todos, action),
        filter: filterReducer(state.filter, action)
    };
}

When to use: Large applications, multiple domains, team collaboration

Pattern 5: Immer for Immutable Updates

Use Immer library to write "mutable" code that's actually immutable:

# Install Immer
npm install immer use-immer
import { useImmerReducer } from 'use-immer';

// Without Immer - complex spread operators
function todoReducer(state: TodoState, action: TodoAction): TodoState {
    switch (action.type) {
        case 'TOGGLE_TODO':
            return {
                ...state,
                todos: state.todos.map(todo =>
                    todo.id === action.payload.id
                        ? { ...todo, completed: !todo.completed }
                        : todo
                )
            };
    }
}

// With Immer - looks like mutations but isn't!
function todoReducer(draft: TodoState, action: TodoAction) {
    switch (action.type) {
        case 'TOGGLE_TODO': {
            const todo = draft.todos.find(t => t.id === action.payload.id);
            if (todo) {
                todo.completed = !todo.completed;
            }
            break;
        }
    }
}

// Use it
const [state, dispatch] = useImmerReducer(todoReducer, initialState);

✅ Immer Benefits

  • Simpler, more readable code
  • Less boilerplate (no spread operators everywhere)
  • Still immutable under the hood
  • Great for deeply nested updates

Pattern 6: Async Actions with useReducer

Handle async operations by dispatching multiple actions:

// State includes loading and error
interface DataState {
    data: User | null;
    loading: boolean;
    error: string | null;
}

type DataAction =
    | { type: 'FETCH_START' }
    | { type: 'FETCH_SUCCESS'; payload: User }
    | { type: 'FETCH_ERROR'; payload: string };

function dataReducer(state: DataState, action: DataAction): DataState {
    switch (action.type) {
        case 'FETCH_START':
            return {
                ...state,
                loading: true,
                error: null
            };
        case 'FETCH_SUCCESS':
            return {
                data: action.payload,
                loading: false,
                error: null
            };
        case 'FETCH_ERROR':
            return {
                ...state,
                loading: false,
                error: action.payload
            };
        default:
            return state;
    }
}

// In component
function UserProfile({ userId }: { userId: number }) {
    const [state, dispatch] = useReducer(dataReducer, {
        data: null,
        loading: false,
        error: null
    });

    const fetchUser = async () => {
        dispatch({ type: 'FETCH_START' });
        
        try {
            const response = await fetch(`/api/users/${userId}`);
            const user = await response.json();
            dispatch({ type: 'FETCH_SUCCESS', payload: user });
        } catch (error) {
            dispatch({ type: 'FETCH_ERROR', payload: error.message });
        }
    };

    useEffect(() => {
        fetchUser();
    }, [userId]);

    if (state.loading) return <div>Loading...</div>;
    if (state.error) return <div>Error: {state.error}</div>;
    if (!state.data) return <div>No data</div>;
    
    return <div>{state.data.name}</div>;
}

⚠️ Important Note

Reducers must be synchronous! Async logic goes in the component (or custom hook), which then dispatches actions to the reducer. The reducer only handles state updates.

Pattern 7: Lazy Initialization

Use lazy initialization for expensive initial state calculations:

// Init function - only runs once
function init(initialCount: number): CounterState {
    // Expensive calculation (e.g., reading from localStorage)
    const savedCount = localStorage.getItem('count');
    
    return {
        count: savedCount ? parseInt(savedCount) : initialCount,
        history: []
    };
}

// Use lazy initialization
const [state, dispatch] = useReducer(reducer, 10, init);
// init(10) is called once on mount

Pattern 8: Middleware Pattern

Add logging or analytics by wrapping dispatch:

function useReducerWithMiddleware<S, A>(
    reducer: (state: S, action: A) => S,
    initialState: S
) {
    const [state, dispatch] = useReducer(reducer, initialState);

    // Enhanced dispatch with logging
    const enhancedDispatch = (action: A) => {
        console.log('Previous State:', state);
        console.log('Action:', action);
        
        dispatch(action);
        
        // State updates after this
        console.log('New State:', state);
    };

    return [state, enhancedDispatch] as const;
}

// Use it
const [state, dispatch] = useReducerWithMiddleware(todoReducer, initialState);

⭐ Best Practices

Follow these best practices to write clean, maintainable useReducer code that scales well.

Do's ✅

✅ Keep Reducers Pure

Reducers must be pure functions with no side effects:

  • Same inputs always produce same outputs
  • No API calls, no timers, no random numbers
  • No mutations - always return new state
// ✅ Pure - always returns same result
function reducer(state: number, action: Action) {
    return state + 1;
}

// ❌ Impure - side effects
function reducer(state: number, action: Action) {
    console.log(state);  // Side effect!
    fetch('/api');       // Side effect!
    return state + Math.random();  // Not deterministic!
}

✅ Use TypeScript

Type everything for safety and great developer experience:

  • Define state interface
  • Use discriminated unions for actions
  • Type the reducer function
  • Use exhaustive checks in default case

✅ Use Action Creators

Always create helper functions for actions:

  • Ensures consistency
  • Provides type safety
  • Makes refactoring easier
  • Documents what data is needed

✅ Handle All Cases

Always include a default case that returns current state:

function reducer(state: State, action: Action): State {
    switch (action.type) {
        case 'ACTION_1':
            return newState;
        case 'ACTION_2':
            return newState;
        default:
            // Always return state for unknown actions
            return state;
    }
}

✅ Keep State Minimal

Only store what you can't calculate:

  • Don't store derived values
  • Don't duplicate data
  • Calculate computed values during render

✅ Name Actions Clearly

Use descriptive, action-oriented names:

  • Use SCREAMING_SNAKE_CASE
  • Start with verb (ADD_TODO, not TODO_ADDED)
  • Be specific (UPDATE_USER_EMAIL vs UPDATE)
  • Group related actions (TODO_ADD, TODO_DELETE)

Don'ts ❌

❌ Don't Mutate State

Never modify state directly - always return a new object:

// ❌ Mutation - breaks React!
function reducer(state: State, action: Action): State {
    state.count++;  // Don't do this!
    return state;
}

// ✅ Create new state
function reducer(state: State, action: Action): State {
    return { ...state, count: state.count + 1 };
}

❌ Don't Put Too Much in One Reducer

Split large reducers into smaller ones:

  • Keep reducers focused on one domain
  • Split when reducer exceeds ~100 lines
  • Use reducer composition pattern

❌ Don't Overthink It

Start with useState, refactor to useReducer when:

  • State updates become complex
  • Multiple related state values
  • State logic scattered across component
  • Bugs from stale closures

❌ Don't Use for Everything

useState is fine for:

  • Simple, independent state
  • Single values
  • State that doesn't interact with other state
  • Form inputs without complex validation

Testing Reducers

Reducers are easy to test because they're pure functions:

// counterReducer.test.ts
import { counterReducer } from './counterReducer';

describe('counterReducer', () => {
    it('should increment count', () => {
        const state = { count: 0, step: 1 };
        const action = { type: 'INCREMENT' as const };
        
        const newState = counterReducer(state, action);
        
        expect(newState.count).toBe(1);
    });

    it('should not mutate original state', () => {
        const state = { count: 0, step: 1 };
        const action = { type: 'INCREMENT' as const };
        
        counterReducer(state, action);
        
        expect(state.count).toBe(0); // Original unchanged
    });

    it('should handle unknown actions', () => {
        const state = { count: 5, step: 1 };
        const action = { type: 'UNKNOWN' as any };
        
        const newState = counterReducer(state, action);
        
        expect(newState).toBe(state); // Same reference
    });
});

💡 Testing Benefits

  • No need to render components
  • No need to mock APIs
  • Fast, focused tests
  • Easy to test edge cases
  • High confidence in state logic

Performance Considerations

When useReducer Helps Performance

  • Callback Stability - dispatch never changes (safe in deps)
  • Deep Updates - Update nested state without re-creating callbacks
  • Multiple Updates - Batch related state changes

Performance Tips

  • Use React.memo for expensive child components
  • Pass dispatch instead of callbacks to children
  • Split large state into multiple reducers
  • Consider Context + useReducer for global state

📚 Summary

Congratulations! You've mastered the useReducer Hook. Let's recap what you've learned.

🎯 Key Takeaways

  • useReducer is for complex state - When useState gets messy, reach for useReducer
  • Reducers are pure functions - Take (state, action), return new state
  • Actions describe what happened - Use action creators for consistency
  • TypeScript makes it better - Type safety prevents bugs
  • Separation of concerns - Reducer handles logic, component handles UI
  • Easy to test - Pure functions are simple to unit test
  • Scales well - Add features without increasing complexity

What You Learned

Concept What You Can Do
Reducer Pattern Understand how reducers work and why they're useful
useReducer Syntax Use useReducer correctly with proper TypeScript types
Actions Create action types and action creator functions
Complex State Manage multi-piece state with a single reducer
Patterns Apply common patterns for better code
Best Practices Write maintainable, testable reducer code

When to Use Each Hook

graph TD A[Need to manage state?] --> B{State is simple?} B -->|Yes| C{Single value?} B -->|No| D[useReducer] C -->|Yes| E[useState] C -->|No| F{Updates depend on previous state?} F -->|Complex dependency| D F -->|Simple| E style E fill:#48bb78,stroke:#333,stroke-width:2px,color:#fff style D fill:#667eea,stroke:#333,stroke-width:2px,color:#fff

Next Steps

🚀 Continue Learning

Now that you know useReducer, you're ready for:

  • Lesson 5.2: useContext - Share state across components
  • Context + useReducer - Global state management pattern
  • useRef - Mutable values and DOM access
  • Performance Hooks - useMemo and useCallback

Practice Projects

✅ Solidify Your Knowledge

Build these projects to master useReducer:

  1. Kanban Board - Drag and drop tasks between columns
  2. Expense Tracker - Add, edit, delete expenses with categories
  3. Quiz App - Multiple questions, score tracking, timer
  4. Chat Application - Messages, users, typing indicators
  5. E-commerce Cart - Products, quantities, coupons, checkout

Common Questions

Q: Should I always use useReducer instead of useState?

A: No! useState is perfect for simple state. Use useReducer when state logic becomes complex, you have multiple related state values, or updates depend on previous state in non-trivial ways.

Q: Can I have multiple useReducer calls in one component?

A: Yes! You can use multiple useReducer hooks for different domains of state. For example, one for form data and another for UI state.

Q: How do I handle async operations with useReducer?

A: Async logic goes outside the reducer (in the component or custom hook). Dispatch actions before and after the async operation (FETCH_START, FETCH_SUCCESS, FETCH_ERROR).

Q: Is useReducer better for performance?

A: Not necessarily. The main benefit is the stable dispatch function, which is safe to pass to children and include in dependency arrays. For actual performance optimization, use React.memo, useMemo, and useCallback.

Q: Should I use Redux or useReducer?

A: For component-level or small app state, useReducer is great. For large apps with complex global state, time-travel debugging needs, or extensive middleware requirements, Redux might be better. Many apps don't need Redux anymore thanks to useReducer + Context!

🎉 Congratulations! 🎉

You've completed the useReducer lesson and gained a powerful tool for managing complex state. You can now build scalable React applications with confidence!

Keep practicing, keep building, and keep learning! 🚀