useReducer for Complex State

Managing advanced state patterns in React with the useReducer Hook

Introduction to Complex State Management

As React applications grow in complexity, managing state becomes increasingly challenging. Multiple state updates, interdependent state values, and complex state transitions can make code difficult to understand and maintain when using only the useState Hook.

Real-world analogy: Think of state management like controlling a sophisticated machine. With useState, you're directly flipping individual switches and turning dials. With useReducer, you're using a control panel where you issue commands ("actions") that trigger predefined sequences of switch and dial adjustments according to a central set of rules.

The useReducer Hook provides a more structured approach to state management, inspired by Redux, that's particularly useful for complex state logic.

flowchart TD A[React Component] --> B[useState] A --> C[useReducer] B --> B1[Simple State] B --> B2[Independent Updates] B --> B3[Few State Transitions] C --> C1[Complex State Objects] C --> C2[Related State Updates] C --> C3[Many State Transitions] C --> C4[Predictable State Changes] style A fill:#f9f,stroke:#333,stroke-width:2px style C fill:#bbf,stroke:#33a,stroke-width:2px

useState vs useReducer

Let's compare the two main approaches to state management in React functional components:

Feature useState useReducer
State Structure Usually primitive values or simple objects Complex objects or interrelated data
Update Logic Spread directly throughout component Centralized in reducer function
Update Patterns Direct state setting Dispatching actions
Predictability Less structured, more ad-hoc More structured, predictable
Testing Component tests Separate pure reducer testing
Debugging More difficult with scattered updates Easier with actions and centralized logic
Learning Curve Lower Higher (requires understanding reducers)

Example Comparison

Using useState


function Counter() {
  const [count, setCount] = useState(0);
  
  const increment = () => {
    setCount(count + 1);
  };
  
  const decrement = () => {
    setCount(count - 1);
  };
  
  const reset = () => {
    setCount(0);
  };
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}
          

Using useReducer


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

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

For this simple counter example, useState is more concise. But as components grow in complexity, useReducer starts to shine by making state transitions more explicit and organized.

When to Use useReducer

The useReducer Hook is particularly beneficial in these scenarios:

If your component state is simple (a few independent values) and the updates are straightforward, useState is often the better choice for its simplicity.

flowchart TD A{Is state a
complex object?} -->|Yes| U[Use useReducer] A -->|No| B{Do state changes
depend on previous
state?} B -->|Yes| C{Are there many
different ways to
update state?} B -->|No| S[Consider useState] C -->|Yes| U C -->|No| D{Do you need to
share update logic?} D -->|Yes| U D -->|No| S style U fill:#bbf,stroke:#33a,stroke-width:2px style S fill:#fbb,stroke:#a33,stroke-width:2px

Understanding useReducer

Basic Syntax


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

The Reducer Function

A reducer function follows a simple signature:


function reducer(state, action) {
  // Return new state based on action
}
      

Reducers should:

Actions

Actions are plain JavaScript objects that describe what happened:


// Simple action
{ type: 'INCREMENT' }

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

By convention, actions typically include:

sequenceDiagram participant C as Component participant D as Dispatch participant R as Reducer participant S as State C->>D: dispatch({ type: 'INCREMENT' }) D->>R: reducer(currentState, action) R->>S: Return new state S->>C: Re-render with new state

Building a Complete Example

Let's build a more complex example: a shopping cart system that demonstrates the power of useReducer.


import React, { useReducer } from 'react';

// Initial state
const initialState = {
  items: [],
  total: 0,
  isCheckingOut: false,
  error: null
};

// Action types (defined as constants to avoid typos)
const ADD_ITEM = 'ADD_ITEM';
const REMOVE_ITEM = 'REMOVE_ITEM';
const UPDATE_QUANTITY = 'UPDATE_QUANTITY';
const CHECKOUT_START = 'CHECKOUT_START';
const CHECKOUT_SUCCESS = 'CHECKOUT_SUCCESS';
const CHECKOUT_FAILURE = 'CHECKOUT_FAILURE';
const CLEAR_CART = 'CLEAR_CART';

// Reducer function
function cartReducer(state, action) {
  switch (action.type) {
    case ADD_ITEM: {
      const newItem = action.payload;
      
      // Check if item already exists in cart
      const existingItemIndex = state.items.findIndex(
        item => item.id === newItem.id
      );
      
      let updatedItems;
      
      if (existingItemIndex >= 0) {
        // Item exists, update quantity
        updatedItems = [...state.items];
        updatedItems[existingItemIndex] = {
          ...updatedItems[existingItemIndex],
          quantity: updatedItems[existingItemIndex].quantity + 1
        };
      } else {
        // Item is new, add to cart with quantity 1
        updatedItems = [...state.items, { ...newItem, quantity: 1 }];
      }
      
      // Calculate new total
      const newTotal = updatedItems.reduce(
        (sum, item) => sum + item.price * item.quantity,
        0
      );
      
      return {
        ...state,
        items: updatedItems,
        total: newTotal,
        error: null
      };
    }
    
    case REMOVE_ITEM: {
      const itemId = action.payload;
      
      // Filter out the item
      const updatedItems = state.items.filter(item => item.id !== itemId);
      
      // Calculate new total
      const newTotal = updatedItems.reduce(
        (sum, item) => sum + item.price * item.quantity,
        0
      );
      
      return {
        ...state,
        items: updatedItems,
        total: newTotal
      };
    }
    
    case UPDATE_QUANTITY: {
      const { itemId, quantity } = action.payload;
      
      // Validate quantity
      if (quantity < 1) {
        return {
          ...state,
          error: "Quantity must be at least 1"
        };
      }
      
      // Update item quantity
      const updatedItems = state.items.map(item =>
        item.id === itemId
          ? { ...item, quantity }
          : item
      );
      
      // Calculate new total
      const newTotal = updatedItems.reduce(
        (sum, item) => sum + item.price * item.quantity,
        0
      );
      
      return {
        ...state,
        items: updatedItems,
        total: newTotal,
        error: null
      };
    }
    
    case CHECKOUT_START:
      return {
        ...state,
        isCheckingOut: true,
        error: null
      };
    
    case CHECKOUT_SUCCESS:
      return {
        ...initialState // Reset to empty cart
      };
    
    case CHECKOUT_FAILURE:
      return {
        ...state,
        isCheckingOut: false,
        error: action.payload
      };
    
    case CLEAR_CART:
      return {
        ...initialState
      };
    
    default:
      return state;
  }
}

// Component using the reducer
function ShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);
  const { items, total, isCheckingOut, error } = state;
  
  // Sample products (in a real app, these would come from an API)
  const products = [
    { id: 1, name: 'Product 1', price: 10.99 },
    { id: 2, name: 'Product 2', price: 24.99 },
    { id: 3, name: 'Product 3', price: 5.99 }
  ];
  
  const handleAddItem = (product) => {
    dispatch({ type: ADD_ITEM, payload: product });
  };
  
  const handleRemoveItem = (itemId) => {
    dispatch({ type: REMOVE_ITEM, payload: itemId });
  };
  
  const handleUpdateQuantity = (itemId, quantity) => {
    dispatch({
      type: UPDATE_QUANTITY,
      payload: { itemId, quantity: parseInt(quantity, 10) }
    });
  };
  
  const handleCheckout = async () => {
    // In a real app, this would be an API call
    dispatch({ type: CHECKOUT_START });
    
    try {
      // Simulate API call
      await new Promise(resolve => setTimeout(resolve, 1000));
      
      // Simulate successful checkout
      dispatch({ type: CHECKOUT_SUCCESS });
    } catch (error) {
      dispatch({ type: CHECKOUT_FAILURE, payload: error.message });
    }
  };
  
  const handleClearCart = () => {
    dispatch({ type: CLEAR_CART });
  };
  
  return (
    <div className="shopping-cart">
      <h2>Shopping Cart</h2>
      
      {error && (
        <div className="error-message">Error: {error}</div>
      )}
      
      <div className="product-list">
        <h3>Available Products</h3>
        <ul>
          {products.map(product => (
            <li key={product.id}>
              {product.name} - ${product.price.toFixed(2)}
              <button onClick={() => handleAddItem(product)}>
                Add to Cart
              </button>
            </li>
          ))}
        </ul>
      </div>
      
      <div className="cart-items">
        <h3>Cart Items</h3>
        {items.length === 0 ? (
          <p>Your cart is empty</p>
        ) : (
          <ul>
            {items.map(item => (
              <li key={item.id}>
                {item.name} - ${item.price.toFixed(2)} × 
                <input
                  type="number"
                  min="1"
                  value={item.quantity}
                  onChange={(e) => handleUpdateQuantity(item.id, e.target.value)}
                  style={{ width: '40px' }}
                />
                = ${(item.price * item.quantity).toFixed(2)}
                <button onClick={() => handleRemoveItem(item.id)}>
                  Remove
                </button>
              </li>
            ))}
          </ul>
        )}
      </div>
      
      <div className="cart-summary">
        <p><strong>Total: ${total.toFixed(2)}</strong></p>
        
        <button
          onClick={handleCheckout}
          disabled={items.length === 0 || isCheckingOut}
        >
          {isCheckingOut ? 'Processing...' : 'Checkout'}
        </button>
        
        <button
          onClick={handleClearCart}
          disabled={items.length === 0}
        >
          Clear Cart
        </button>
      </div>
    </div>
  );
}
      

This shopping cart example demonstrates several key benefits of useReducer:

Advanced useReducer Patterns

Lazy Initialization

For expensive initial state calculations, you can use lazy initialization by passing a function as the third argument to useReducer:


function init(initialCount) {
  // This could be a complex calculation or fetch from localStorage
  return {
    count: initialCount,
    history: [],
    lastUpdated: new Date().toISOString()
  };
}

function counterReducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {
        ...state,
        count: state.count + 1,
        history: [...state.history, { type: 'increment', timestamp: new Date().toISOString() }],
        lastUpdated: new Date().toISOString()
      };
    // Other cases...
    default:
      return state;
  }
}

function Counter({ initialCount }) {
  // The init function is only called during the initial render
  const [state, dispatch] = useReducer(counterReducer, initialCount, init);
  
  // Component JSX...
}
      

This is particularly useful when:

Multiple Reducers

For very complex applications, you can use multiple useReducer Hooks to separate concerns:


function UserPage() {
  // User profile state
  const [userState, userDispatch] = useReducer(userReducer, initialUserState);
  
  // User preferences state
  const [prefsState, prefsDispatch] = useReducer(prefsReducer, initialPrefsState);
  
  // Activity log state
  const [activityState, activityDispatch] = useReducer(
    activityReducer, 
    initialActivityState
  );
  
  // Component JSX using multiple state objects and dispatchers...
}
      

Combining with useContext for Global State

You can create a global state management solution by combining useReducer with useContext:


// Create contexts
const StateContext = React.createContext();
const DispatchContext = React.createContext();

// Provider component
function AppProvider({ children }) {
  const [state, dispatch] = useReducer(appReducer, initialState);
  
  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
}

// Custom hooks to use the context
function useAppState() {
  const context = useContext(StateContext);
  if (context === undefined) {
    throw new Error('useAppState must be used within an AppProvider');
  }
  return context;
}

function useAppDispatch() {
  const context = useContext(DispatchContext);
  if (context === undefined) {
    throw new Error('useAppDispatch must be used within an AppProvider');
  }
  return context;
}

// Usage in a component
function SomeComponent() {
  const state = useAppState();
  const dispatch = useAppDispatch();
  
  // Use state and dispatch...
}

// App wrapper
function App() {
  return (
    <AppProvider>
      <MainLayout />
    </AppProvider>
  );
}
      

This pattern creates a Redux-like state management system without needing any external libraries.

Optimizing State Updates

Avoiding Deep Nesting

When working with complex state objects, deeply nested structures can make updates verbose and error-prone:

⚠️ Deeply Nested State


// Initial state
const initialState = {
  user: {
    profile: {
      personal: {
        name: 'John',
        age: 30
      },
      work: {
        title: 'Developer',
        company: 'Tech Co'
      }
    },
    preferences: {
      theme: 'dark',
      notifications: true
    }
  }
};

// Updating deeply nested property
function reducer(state, action) {
  switch (action.type) {
    case 'UPDATE_NAME':
      return {
        ...state,
        user: {
          ...state.user,
          profile: {
            ...state.user.profile,
            personal: {
              ...state.user.profile.personal,
              name: action.payload
            }
          }
        }
      };
    // Other cases...
  }
}
          

✅ Flatter State Structure


// Initial state
const initialState = {
  userPersonal: {
    name: 'John',
    age: 30
  },
  userWork: {
    title: 'Developer',
    company: 'Tech Co'
  },
  userPreferences: {
    theme: 'dark',
    notifications: true
  }
};

// Updating property in flatter structure
function reducer(state, action) {
  switch (action.type) {
    case 'UPDATE_NAME':
      return {
        ...state,
        userPersonal: {
          ...state.userPersonal,
          name: action.payload
        }
      };
    // Other cases...
  }
}
          

Using Immer for Immutable Updates

Immer is a library that allows you to write "mutating" code while still preserving immutability under the hood:


import { useReducer } from 'react';
import produce from 'immer';

// Initial state
const initialState = {
  user: {
    profile: {
      personal: {
        name: 'John',
        age: 30
      },
      work: {
        title: 'Developer',
        company: 'Tech Co'
      }
    },
    preferences: {
      theme: 'dark',
      notifications: true
    }
  }
};

// Immer-powered reducer
function reducer(state, action) {
  return produce(state, draft => {
    switch (action.type) {
      case 'UPDATE_NAME':
        // "Mutate" the draft safely
        draft.user.profile.personal.name = action.payload;
        break;
      
      case 'INCREMENT_AGE':
        draft.user.profile.personal.age += 1;
        break;
      
      case 'TOGGLE_THEME':
        draft.user.preferences.theme = 
          draft.user.preferences.theme === 'dark' ? 'light' : 'dark';
        break;
      
      // Other cases...
    }
  });
}

function UserProfile() {
  const [state, dispatch] = useReducer(reducer, initialState);
  
  // Component JSX...
}
      

Immer makes complex state updates much more readable and less error-prone, especially for deeply nested state objects.

Action Creators

As your application grows, you can create action creator functions to standardize and encapsulate action creation logic:


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

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

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

// Component using action creators
function TodoApp() {
  const [state, dispatch] = useReducer(todoReducer, initialState);
  const [text, setText] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (!text.trim()) return;
    
    dispatch(addTodo(text));
    setText('');
  };
  
  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input 
          value={text} 
          onChange={(e) => setText(e.target.value)} 
        />
        <button type="submit">Add Todo</button>
      </form>
      
      <ul>
        {state.todos.map(todo => (
          <li key={todo.id}>
            <span
              style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
              onClick={() => dispatch(toggleTodo(todo.id))}
            >
              {todo.text}
            </span>
            <button onClick={() => dispatch(deleteTodo(todo.id))}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}
      

Action creators provide several benefits:

Handling Asynchronous Actions

Reducers must be pure functions, so they can't contain asynchronous logic. However, you can handle async operations by dispatching different actions at each stage of the async process:


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

// Component using async actions
function DataComponent() {
  const [state, dispatch] = useReducer(dataReducer, {
    data: null,
    loading: false,
    error: null
  });
  
  const fetchData = async () => {
    dispatch({ type: 'FETCH_START' });
    
    try {
      const response = await fetch('https://api.example.com/data');
      
      if (!response.ok) {
        throw new Error(`HTTP error! Status: ${response.status}`);
      }
      
      const data = await response.json();
      dispatch({ type: 'FETCH_SUCCESS', payload: data });
    } catch (error) {
      dispatch({ type: 'FETCH_ERROR', payload: error.message });
    }
  };
  
  useEffect(() => {
    fetchData();
  }, []);
  
  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>
      <h2>Data</h2>
      <pre>{JSON.stringify(state.data, null, 2)}</pre>
      <button onClick={fetchData}>Refresh Data</button>
    </div>
  );
}
      

For more complex async patterns, you might want to implement a middleware-like approach similar to Redux Thunk or Redux Saga, but that's beyond the scope of this lecture.

Testing Reducers

One of the major benefits of using reducers is that they are pure functions which makes them easy to test:


// Reducer to test
function counterReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    case 'SET':
      return { count: action.payload };
    default:
      return state;
  }
}

// Tests (using Jest)
describe('counterReducer', () => {
  // Initial state for tests
  const initialState = { count: 0 };
  
  test('should return initial state when no action is provided', () => {
    expect(counterReducer(initialState, {})).toEqual(initialState);
  });
  
  test('should increment count', () => {
    expect(counterReducer(initialState, { type: 'INCREMENT' }))
      .toEqual({ count: 1 });
  });
  
  test('should decrement count', () => {
    expect(counterReducer({ count: 5 }, { type: 'DECREMENT' }))
      .toEqual({ count: 4 });
  });
  
  test('should set count to specific value', () => {
    expect(counterReducer(initialState, { type: 'SET', payload: 10 }))
      .toEqual({ count: 10 });
  });
  
  test('should handle consecutive actions correctly', () => {
    let state = initialState;
    state = counterReducer(state, { type: 'INCREMENT' });
    state = counterReducer(state, { type: 'INCREMENT' });
    state = counterReducer(state, { type: 'DECREMENT' });
    
    expect(state).toEqual({ count: 1 });
  });
});
      

This demonstrates how easy it is to test reducers in isolation, without needing to render components or mock React's internal APIs.

Real-World Application: Form Management

Forms are a classic example where useReducer shines. Let's implement a multi-step registration form with validation:


import React, { useReducer } from 'react';

// Form state and actions
const initialState = {
  step: 1,
  formData: {
    // Personal details (step 1)
    firstName: '',
    lastName: '',
    email: '',
    
    // Address (step 2)
    address: '',
    city: '',
    state: '',
    zipCode: '',
    
    // Account details (step 3)
    username: '',
    password: '',
    confirmPassword: ''
  },
  errors: {},
  isSubmitting: false,
  isSubmitted: false
};

// Action types
const UPDATE_FIELD = 'UPDATE_FIELD';
const VALIDATE_STEP = 'VALIDATE_STEP';
const NEXT_STEP = 'NEXT_STEP';
const PREV_STEP = 'PREV_STEP';
const SUBMIT_FORM = 'SUBMIT_FORM';
const SUBMIT_SUCCESS = 'SUBMIT_SUCCESS';
const SUBMIT_ERROR = 'SUBMIT_ERROR';

// Form reducer
function formReducer(state, action) {
  switch (action.type) {
    case UPDATE_FIELD:
      return {
        ...state,
        formData: {
          ...state.formData,
          [action.field]: action.value
        },
        // Clear error for this field when it's updated
        errors: {
          ...state.errors,
          [action.field]: undefined
        }
      };
    
    case VALIDATE_STEP: {
      const errors = {};
      const currentStep = state.step;
      
      if (currentStep === 1) {
        // Validate step 1 fields
        if (!state.formData.firstName.trim()) {
          errors.firstName = 'First name is required';
        }
        
        if (!state.formData.lastName.trim()) {
          errors.lastName = 'Last name is required';
        }
        
        if (!state.formData.email.trim()) {
          errors.email = 'Email is required';
        } else if (!/\S+@\S+\.\S+/.test(state.formData.email)) {
          errors.email = 'Email is invalid';
        }
      } else if (currentStep === 2) {
        // Validate step 2 fields
        if (!state.formData.address.trim()) {
          errors.address = 'Address is required';
        }
        
        if (!state.formData.city.trim()) {
          errors.city = 'City is required';
        }
        
        if (!state.formData.state.trim()) {
          errors.state = 'State is required';
        }
        
        if (!state.formData.zipCode.trim()) {
          errors.zipCode = 'ZIP code is required';
        } else if (!/^\d{5}(-\d{4})?$/.test(state.formData.zipCode)) {
          errors.zipCode = 'ZIP code is invalid';
        }
      } else if (currentStep === 3) {
        // Validate step 3 fields
        if (!state.formData.username.trim()) {
          errors.username = 'Username is required';
        } else if (state.formData.username.length < 4) {
          errors.username = 'Username must be at least 4 characters';
        }
        
        if (!state.formData.password) {
          errors.password = 'Password is required';
        } else if (state.formData.password.length < 8) {
          errors.password = 'Password must be at least 8 characters';
        }
        
        if (!state.formData.confirmPassword) {
          errors.confirmPassword = 'Please confirm your password';
        } else if (state.formData.confirmPassword !== state.formData.password) {
          errors.confirmPassword = 'Passwords do not match';
        }
      }
      
      return {
        ...state,
        errors
      };
    }
    
    case NEXT_STEP: {
      // Only proceed if there are no errors
      if (Object.keys(state.errors).length === 0) {
        return {
          ...state,
          step: state.step + 1
        };
      }
      return state;
    }
    
    case PREV_STEP:
      return {
        ...state,
        step: Math.max(1, state.step - 1)
      };
    
    case SUBMIT_FORM:
      return {
        ...state,
        isSubmitting: true
      };
    
    case SUBMIT_SUCCESS:
      return {
        ...state,
        isSubmitting: false,
        isSubmitted: true
      };
    
    case SUBMIT_ERROR:
      return {
        ...state,
        isSubmitting: false,
        errors: {
          ...state.errors,
          form: action.error
        }
      };
    
    default:
      return state;
  }
}

// Multi-step registration form component
function RegistrationForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    dispatch({
      type: UPDATE_FIELD,
      field: name,
      value
    });
  };
  
  const handleNextStep = () => {
    dispatch({ type: VALIDATE_STEP });
    
    // Check if there are errors after validation
    if (Object.keys(state.errors).length === 0) {
      dispatch({ type: NEXT_STEP });
    }
  };
  
  const handlePrevStep = () => {
    dispatch({ type: PREV_STEP });
  };
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    
    dispatch({ type: VALIDATE_STEP });
    
    // Only submit if there are no errors
    if (Object.keys(state.errors).length === 0) {
      dispatch({ type: SUBMIT_FORM });
      
      try {
        // Simulate API call
        await new Promise(resolve => setTimeout(resolve, 1000));
        
        // Simulate successful submission
        dispatch({ type: SUBMIT_SUCCESS });
      } catch (error) {
        dispatch({
          type: SUBMIT_ERROR,
          error: error.message || 'Submission failed'
        });
      }
    }
  };
  
  // Render different step content based on current step
  const renderStepContent = () => {
    const { formData, errors } = state;
    
    switch (state.step) {
      case 1:
        return (
          <div className="form-step">
            <h2>Personal Information</h2>
            
            <div className="form-group">
              <label htmlFor="firstName">First Name:</label>
              <input
                id="firstName"
                name="firstName"
                value={formData.firstName}
                onChange={handleChange}
                className={errors.firstName ? 'error' : ''}
              />
              {errors.firstName && (
                <div className="error-message">{errors.firstName}</div>
              )}
            </div>
            
            <div className="form-group">
              <label htmlFor="lastName">Last Name:</label>
              <input
                id="lastName"
                name="lastName"
                value={formData.lastName}
                onChange={handleChange}
                className={errors.lastName ? 'error' : ''}
              />
              {errors.lastName && (
                <div className="error-message">{errors.lastName}</div>
              )}
            </div>
            
            <div className="form-group">
              <label htmlFor="email">Email:</label>
              <input
                id="email"
                name="email"
                type="email"
                value={formData.email}
                onChange={handleChange}
                className={errors.email ? 'error' : ''}
              />
              {errors.email && (
                <div className="error-message">{errors.email}</div>
              )}
            </div>
          </div>
        );
      
      case 2:
        return (
          <div className="form-step">
            <h2>Address Information</h2>
            
            <div className="form-group">
              <label htmlFor="address">Address:</label>
              <input
                id="address"
                name="address"
                value={formData.address}
                onChange={handleChange}
                className={errors.address ? 'error' : ''}
              />
              {errors.address && (
                <div className="error-message">{errors.address}</div>
              )}
            </div>
            
            <div className="form-group">
              <label htmlFor="city">City:</label>
              <input
                id="city"
                name="city"
                value={formData.city}
                onChange={handleChange}
                className={errors.city ? 'error' : ''}
              />
              {errors.city && (
                <div className="error-message">{errors.city}</div>
              )}
            </div>
            
            <div className="form-group">
              <label htmlFor="state">State:</label>
              <select
                id="state"
                name="state"
                value={formData.state}
                onChange={handleChange}
                className={errors.state ? 'error' : ''}
              >
                <option value="">Select a state</option>
                <option value="CA">California</option>
                <option value="NY">New York</option>
                <option value="TX">Texas</option>
                {/* More states... */}
              </select>
              {errors.state && (
                <div className="error-message">{errors.state}</div>
              )}
            </div>
            
            <div className="form-group">
              <label htmlFor="zipCode">ZIP Code:</label>
              <input
                id="zipCode"
                name="zipCode"
                value={formData.zipCode}
                onChange={handleChange}
                className={errors.zipCode ? 'error' : ''}
              />
              {errors.zipCode && (
                <div className="error-message">{errors.zipCode}</div>
              )}
            </div>
          </div>
        );
      
      case 3:
        return (
          <div className="form-step">
            <h2>Account Information</h2>
            
            <div className="form-group">
              <label htmlFor="username">Username:</label>
              <input
                id="username"
                name="username"
                value={formData.username}
                onChange={handleChange}
                className={errors.username ? 'error' : ''}
              />
              {errors.username && (
                <div className="error-message">{errors.username}</div>
              )}
            </div>
            
            <div className="form-group">
              <label htmlFor="password">Password:</label>
              <input
                id="password"
                name="password"
                type="password"
                value={formData.password}
                onChange={handleChange}
                className={errors.password ? 'error' : ''}
              />
              {errors.password && (
                <div className="error-message">{errors.password}</div>
              )}
            </div>
            
            <div className="form-group">
              <label htmlFor="confirmPassword">Confirm Password:</label>
              <input
                id="confirmPassword"
                name="confirmPassword"
                type="password"
                value={formData.confirmPassword}
                onChange={handleChange}
                className={errors.confirmPassword ? 'error' : ''}
              />
              {errors.confirmPassword && (
                <div className="error-message">{errors.confirmPassword}</div>
              )}
            </div>
          </div>
        );
      
      default:
        return null;
    }
  };
  
  // Show success message if form is submitted
  if (state.isSubmitted) {
    return (
      <div className="registration-success">
        <h2>Registration Successful!</h2>
        <p>Thank you for registering, {state.formData.firstName}!</p>
      </div>
    );
  }
  
  return (
    <div className="registration-form">
      {/* Progress indicator */}
      <div className="form-progress">
        {[1, 2, 3].map(step => (
          <div
            key={step}
            className={`progress-step ${state.step === step ? 'active' : ''} 
                       ${state.step > step ? 'completed' : ''}`}
          >
            {step}
          </div>
        ))}
      </div>
      
      {/* Form error message */}
      {state.errors.form && (
        <div className="form-error">{state.errors.form}</div>
      )}
      
      {/* Form content */}
      <form onSubmit={handleSubmit}>
        {renderStepContent()}
        
        {/* Form navigation */}
        <div className="form-navigation">
          {state.step > 1 && (
            <button
              type="button"
              onClick={handlePrevStep}
              disabled={state.isSubmitting}
            >
              Previous
            </button>
          )}
          
          {state.step < 3 ? (
            <button
              type="button"
              onClick={handleNextStep}
              disabled={state.isSubmitting}
            >
              Next
            </button>
          ) : (
            <button
              type="submit"
              disabled={state.isSubmitting}
            >
              {state.isSubmitting ? 'Submitting...' : 'Register'}
            </button>
          )}
        </div>
      </form>
    </div>
  );
}
      

This complex form example demonstrates many of the strengths of useReducer:

Practice Exercises

Exercise 1: Todo List with Categories

Create a todo list application with the following features:

Use useReducer to manage the application state.

Exercise 2: Game State Management

Implement a simple game (like tic-tac-toe) using useReducer to manage:

Exercise 3: User Authentication Flow

Create a user authentication flow with the following states:

Implement this using useReducer and simulate the API calls with timeouts.

Summary

In this lecture, we've covered:

The useReducer Hook provides a powerful way to manage complex state logic in React applications. While it introduces more boilerplate than useState, it brings significant benefits in terms of code organization, predictability, and maintainability as your application grows.

By centralizing your state logic in reducer functions and using a clear action-based approach to state updates, you can build more robust and maintainable React applications that scale well with increasing complexity.

Further Resources