useReducer Hook: State Management Like Redux

Managing Complex State with Predictable Updates

Welcome Back! Time for useReducer

Imagine you're managing a bank account. You can't just change the balance directly - you need to perform specific actions like "deposit", "withdraw", or "transfer". Each action follows strict rules and creates a record. That's exactly what useReducer does for your React state!

graph LR A[Current State] --> B[Action] B --> C[Reducer Function] C --> D[New State] D --> E[UI Update] E --> F[User Action] F --> B style A fill:#ff9999 style D fill:#99ff99 style C fill:#9999ff

Understanding useReducer

useReducer is like useState's more disciplined cousin. Instead of directly setting state, you dispatch actions that describe what happened, and a reducer function decides how the state should change.

The Anatomy of useReducer

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

// Where:
// - state: current state value
// - dispatch: function to send actions
// - reducer: function that handles state updates
// - initialState: starting state value

Your First Reducer

// Counter with useReducer
const initialState = { count: 0 };

function reducer(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(reducer, initialState);
  
  return (
    <div>
      <h2>Count: {state.count}</h2>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>
        +
      </button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>
        -
      </button>
      <button onClick={() => dispatch({ type: 'RESET' })}>
        Reset
      </button>
    </div>
  );
}

useState vs useReducer

When should you use each? Let's compare:

useState useReducer
Simple state (single values) Complex state (objects, arrays)
Independent state updates State updates depend on previous state
Few state transitions Many state transitions
Local component state State shared across components

Example: Todo List Comparison

// With useState - gets messy quickly
function TodosWithState() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all');
  const [sortBy, setSortBy] = useState('date');
  const [searchTerm, setSearchTerm] = useState('');
  
  const addTodo = (text) => {
    setTodos([...todos, { id: Date.now(), text, completed: false }]);
  };
  
  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };
  
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };
  
  // More functions for filter, sort, search...
}

// With useReducer - clean and organized
const initialState = {
  todos: [],
  filter: 'all',
  sortBy: 'date',
  searchTerm: ''
};

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

function TodosWithReducer() {
  const [state, dispatch] = useReducer(todoReducer, initialState);
  
  return (
    <div>
      {/* Clean component with just dispatches */}
      <button onClick={() => dispatch({ 
        type: 'ADD_TODO', 
        payload: 'New todo' 
      })}>
        Add Todo
      </button>
      {/* Rest of the UI */}
    </div>
  );
}

Advanced useReducer Patterns

Pattern 1: Action Creators

// Action creators make your code more maintainable
const todoActions = {
  addTodo: (text) => ({
    type: 'ADD_TODO',
    payload: text
  }),
  toggleTodo: (id) => ({
    type: 'TOGGLE_TODO',
    payload: id
  }),
  deleteTodo: (id) => ({
    type: 'DELETE_TODO',
    payload: id
  }),
  setFilter: (filter) => ({
    type: 'SET_FILTER',
    payload: filter
  })
};

// Usage
dispatch(todoActions.addTodo('Learn useReducer'));

Pattern 2: Async Actions with useReducer

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

function DataFetcher() {
  const [state, dispatch] = useReducer(asyncReducer, {
    data: null,
    loading: false,
    error: null
  });
  
  const fetchData = async () => {
    dispatch({ type: 'FETCH_START' });
    try {
      const response = await fetch('/api/data');
      const data = await response.json();
      dispatch({ type: 'FETCH_SUCCESS', payload: data });
    } catch (error) {
      dispatch({ type: 'FETCH_ERROR', payload: error.message });
    }
  };
  
  return (
    <div>
      {state.loading && <LoadingSpinner />}
      {state.error && <ErrorMessage error={state.error} />}
      {state.data && <DataDisplay data={state.data} />}
      <button onClick={fetchData}>Fetch Data</button>
    </div>
  );
}

Pattern 3: Computed State

// Deriving values from state
function shoppingReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.payload]
      };
    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload)
      };
    case 'UPDATE_QUANTITY':
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload.id
            ? { ...item, quantity: action.payload.quantity }
            : item
        )
      };
    default:
      return state;
  }
}

function ShoppingCart() {
  const [state, dispatch] = useReducer(shoppingReducer, { items: [] });
  
  // Computed values
  const totalItems = state.items.reduce((sum, item) => sum + item.quantity, 0);
  const totalPrice = state.items.reduce(
    (sum, item) => sum + (item.price * item.quantity), 
    0
  );
  
  return (
    <div>
      <h2>Shopping Cart ({totalItems} items)</h2>
      <h3>Total: ${totalPrice.toFixed(2)}</h3>
      {/* Cart items display */}
    </div>
  );
}

Real-World Example: Form Management

Let's build a complex form with validation, multiple steps, and error handling:

// Multi-step form with useReducer
const initialFormState = {
  currentStep: 1,
  formData: {
    personalInfo: {
      firstName: '',
      lastName: '',
      email: ''
    },
    addressInfo: {
      street: '',
      city: '',
      zipCode: ''
    },
    paymentInfo: {
      cardNumber: '',
      expiry: '',
      cvv: ''
    }
  },
  errors: {},
  isSubmitting: false,
  isComplete: false
};

function formReducer(state, action) {
  switch (action.type) {
    case 'UPDATE_FIELD':
      const { section, field, value } = action.payload;
      return {
        ...state,
        formData: {
          ...state.formData,
          [section]: {
            ...state.formData[section],
            [field]: value
          }
        }
      };
    
    case 'SET_ERROR':
      return {
        ...state,
        errors: {
          ...state.errors,
          [action.payload.field]: action.payload.message
        }
      };
    
    case 'CLEAR_ERRORS':
      return {
        ...state,
        errors: {}
      };
    
    case 'NEXT_STEP':
      return {
        ...state,
        currentStep: state.currentStep + 1
      };
    
    case 'PREV_STEP':
      return {
        ...state,
        currentStep: state.currentStep - 1
      };
    
    case 'SUBMIT_START':
      return {
        ...state,
        isSubmitting: true
      };
    
    case 'SUBMIT_SUCCESS':
      return {
        ...state,
        isSubmitting: false,
        isComplete: true
      };
    
    case 'SUBMIT_ERROR':
      return {
        ...state,
        isSubmitting: false,
        errors: action.payload
      };
    
    default:
      return state;
  }
}

function MultiStepForm() {
  const [state, dispatch] = useReducer(formReducer, initialFormState);
  
  const handleFieldChange = (section, field, value) => {
    dispatch({
      type: 'UPDATE_FIELD',
      payload: { section, field, value }
    });
  };
  
  const validateStep = (step) => {
    // Validation logic here
    return true; // or false with errors
  };
  
  const handleNext = () => {
    if (validateStep(state.currentStep)) {
      dispatch({ type: 'NEXT_STEP' });
    }
  };
  
  const handleSubmit = async () => {
    dispatch({ type: 'SUBMIT_START' });
    try {
      const response = await submitForm(state.formData);
      dispatch({ type: 'SUBMIT_SUCCESS' });
    } catch (error) {
      dispatch({ 
        type: 'SUBMIT_ERROR', 
        payload: error.validationErrors 
      });
    }
  };
  
  return (
    <div className="multi-step-form">
      <ProgressIndicator currentStep={state.currentStep} />
      
      {state.currentStep === 1 && (
        <PersonalInfoStep 
          data={state.formData.personalInfo}
          errors={state.errors}
          onChange={handleFieldChange}
        />
      )}
      
      {state.currentStep === 2 && (
        <AddressStep 
          data={state.formData.addressInfo}
          errors={state.errors}
          onChange={handleFieldChange}
        />
      )}
      
      {state.currentStep === 3 && (
        <PaymentStep 
          data={state.formData.paymentInfo}
          errors={state.errors}
          onChange={handleFieldChange}
        />
      )}
      
      <div className="form-navigation">
        {state.currentStep > 1 && (
          <button onClick={() => dispatch({ type: 'PREV_STEP' })}>
            Previous
          </button>
        )}
        
        {state.currentStep < 3 ? (
          <button onClick={handleNext} >Next</button>
        ) : (
          <button 
            onClick={handleSubmit}
            disabled={state.isSubmitting}
          >
            {state.isSubmitting ? 'Submitting...' : 'Submit'}
          </button>
        )}
      </div>
    </div>
  );
}

Combining useReducer with useContext

The ultimate power move: combining useReducer with useContext for app-wide state management!

// Create a powerful state management system
const AppStateContext = createContext();
const AppDispatchContext = createContext();

function appReducer(state, action) {
  switch (action.type) {
    case 'SET_USER':
      return { ...state, user: action.payload };
    case 'SET_THEME':
      return { ...state, theme: action.payload };
    case 'ADD_NOTIFICATION':
      return {
        ...state,
        notifications: [...state.notifications, action.payload]
      };
    // More actions...
    default:
      return state;
  }
}

function AppProvider({ children }) {
  const [state, dispatch] = useReducer(appReducer, {
    user: null,
    theme: 'light',
    notifications: []
  });
  
  return (
    <AppStateContext.Provider value={state}>
      <AppDispatchContext.Provider value={dispatch}>
        {children}
      </AppDispatchContext.Provider>
    </AppStateContext.Provider>
  );
}

// Custom hooks for easy access
function useAppState() {
  const context = useContext(AppStateContext);
  if (!context) {
    throw new Error('useAppState must be used within AppProvider');
  }
  return context;
}

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

// Usage in components
function UserProfile() {
  const { user } = useAppState();
  const dispatch = useAppDispatch();
  
  const handleLogout = () => {
    dispatch({ type: 'SET_USER', payload: null });
  };
  
  return (
    <div>
      <h2>Welcome, {user.name}!</h2>
      <button onClick={handleLogout}>Logout</button>
    </div>
  );
}

Practice Exercises

Exercise 1: Game State Manager

Create a reducer to manage a simple game state with the following actions:

  • START_GAME
  • PAUSE_GAME
  • UPDATE_SCORE
  • NEXT_LEVEL
  • GAME_OVER
// Your task: Create the game reducer
const initialGameState = {
  isPlaying: false,
  isPaused: false,
  score: 0,
  level: 1,
  lives: 3
};

function gameReducer(state, action) {
  // Implement the reducer logic
}

Exercise 2: Shopping Cart with Discounts

Extend a shopping cart reducer to handle:

  • Product quantity limits
  • Discount codes
  • Shipping calculations

Exercise 3: Undo/Redo Functionality

Create a reducer that supports undo and redo operations for a text editor.

// Hint: You'll need to track history
const initialState = {
  past: [],
  present: '',
  future: []
};

Best Practices

1. Keep Reducers Pure

// ❌ Bad: Mutating state
function badReducer(state, action) {
  state.count += 1; // Never mutate!
  return state;
}

// ✅ Good: Return new state
function goodReducer(state, action) {
  return { ...state, count: state.count + 1 };
}

2. Type Your Actions

// Use constants for action types
const ActionTypes = {
  ADD_TODO: 'ADD_TODO',
  REMOVE_TODO: 'REMOVE_TODO',
  TOGGLE_TODO: 'TOGGLE_TODO'
};

// Even better with TypeScript
type Action = 
  | { type: 'ADD_TODO'; payload: string }
  | { type: 'REMOVE_TODO'; payload: number }
  | { type: 'TOGGLE_TODO'; payload: number };

3. Handle Unknown Actions

function reducer(state, action) {
  switch (action.type) {
    // ... cases
    default:
      console.warn(`Unknown action type: ${action.type}`);
      return state; // Always return current state
  }
}

Key Takeaways

When to Use useReducer