Week 9

useReducer

Context API

Error Boundaries

React Hook Form

Practice

Assignment

Front end Track

What is useReducer?

useReducer is a React hook that gives you a more structured way to manage state. You already know useState and for simple values like a counter, a boolean toggle, or a single string, useState is exactly right. But as your components grow, you'll start noticing a pattern: you have several related pieces of state, and updating one often depends on the current value of another. That's when useReducer starts to earn its place.

The idea comes from a broader pattern in programming called a reducer: a function that takes a current state and a description of what happened, and returns the next state. React didn't invent this, it's the same concept behind how databases handle transaction logs, and how the JavaScript Array.prototype.reduce method works (which you've already seen in your core course).

https://www.youtube.com/watch?v=43H_PNDPHtg

Why Not Just Use useState?

Imagine you're building a sign-up form with several fields, some loading state, and error handling. With useState, you might end up with something like this:

const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);

This works, but notice what happens when you submit the form: you need to call setIsLoading(true), setError(null), and potentially update other values โ€” all for a single user action! The logic for what happens when the form is submitted is now scattered across several setter calls.

useReducer lets you describe that action in one place, "the form was submitted", and handle all the resulting state changes together.

The Shape of useReducer

useReducer takes two arguments: a reducer function and an initial state. It returns the current state and a dispatch function.

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

The reducer function is where the logic lives. It takes the current state and an action, and returns the next state:

function reducer(state, action) {
  // based on what happened (action), return the new state
}

An action is just an object that describes what happened. By convention, it always has a type property (a string), and optionally a payload with any extra data needed:

{ type: 'SUBMIT_FORM' }
{ type: 'SET_ERROR', payload: 'Email already in use' }
{ type: 'UPDATE_FIELD', payload: { field: 'username', value: 'alice' } }

A Simple Counter Example

Before jumping to forms, here's the classic counter โ€” it's the clearest way to see the pattern:

import { useReducer } from 'react';

// The reducer: takes state and action, returns new state
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:
      // Always include a default โ€” returning state unchanged is correct
      return state;
  }
}

const initialState = { count: 0 };

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, initialState);

  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>
  );
}

Compare this to useState. The buttons don't contain logic โ€” they just say what happened ('INCREMENT'). All the logic for how state changes lives in one place: the reducer. That separation pays off as complexity grows.

<aside> ๐Ÿ’ก

Info

The switch statement in a reducer is convention, not a requirement. Some teams use if/else chains or even an object map of handlers. switch is the most common pattern you'll encounter in the wild.

</aside>

When to Reach for useReducer

There's no hard rule, but these signals suggest it's time to reach for useReducer:

For a single boolean or a simple string, useState is cleaner. useReducer adds structure, and structure has a cost. Only add it when that cost is worth paying.

<aside> โŒจ๏ธ

Hands On

Start a new React component file. Build a simple traffic light using useReducer. The light has three states: 'red', 'amber', and 'green'. Dispatch a 'NEXT' action to cycle through them in order. Render a circle whose background colour matches the current state. Don't worry about making it pretty โ€” focus on the reducer shape.

</aside>

Modelling Complex Form State

Here's where useReducer really starts to shine. Imagine a multi-field form for adding a new concert to your app:

import { useReducer } from 'react';

const initialState = {
  artist: '',
  venue: '',
  date: '',
  ticketPrice: '',
  isSubmitting: false,
  error: null,
};

function concertFormReducer(state, action) {
  switch (action.type) {
    case 'UPDATE_FIELD':
      // action.payload = { field: 'artist', value: 'Radiohead' }
      return {
        ...state,
        [action.payload.field]: action.payload.value,
      };

    case 'SUBMIT_START':
      return {
        ...state,
        isSubmitting: true,
        error: null,
      };

    case 'SUBMIT_SUCCESS':
      // Reset everything after a successful submission
      return initialState;

    case 'SUBMIT_ERROR':
      return {
        ...state,
        isSubmitting: false,
        error: action.payload,
      };

    default:
      return state;
  }
}

function AddConcertForm() {
  const [state, dispatch] = useReducer(concertFormReducer, initialState);

  function handleFieldChange(e) {
    dispatch({
      type: 'UPDATE_FIELD',
      payload: { field: e.target.name, value: e.target.value },
    });
  }

  async function handleSubmit(e) {
    e.preventDefault();
    dispatch({ type: 'SUBMIT_START' });

    try {
      await saveConcert(state); // pretend this exists
      dispatch({ type: 'SUBMIT_SUCCESS' });
    } catch (err) {
      dispatch({ type: 'SUBMIT_ERROR', payload: err.message });
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="artist"
        value={state.artist}
        onChange={handleFieldChange}
        placeholder="Artist"
      />
      <input
        name="venue"
        value={state.venue}
        onChange={handleFieldChange}
        placeholder="Venue"
      />
      <input
        name="date"
        type="date"
        value={state.date}
        onChange={handleFieldChange}
      />
      <input
        name="ticketPrice"
        type="number"
        value={state.ticketPrice}
        onChange={handleFieldChange}
        placeholder="Ticket price"
      />

      {state.error && <p role="alert">{state.error}</p>}

      <button type="submit" disabled={state.isSubmitting}>
        {state.isSubmitting ? 'Saving...' : 'Add Concert'}
      </button>
    </form>
  );
}

Notice what the submit handler looks like: two dispatch calls that clearly describe what's happening at each stage. The form component doesn't contain any state transition logic itself โ€” that all lives in concertFormReducer, where you can read it, test it, and reason about it independently.

<aside> โš ๏ธ

Always return a new object from your reducer โ€” never mutate the existing state directly. React uses reference equality to detect changes. If you write state.artist = 'Radiohead'; return state;, React won't detect the change and your component won't re-render. Always use spread syntax ({ ...state, artist: 'Radiohead' }) or create a fresh object.

</aside>

Actions and dispatch in Practice

dispatch is the only way to trigger a state change when using useReducer. You can call it anywhere: in event handlers, inside useEffect, after an API call, or even from a child component (if you pass dispatch down as a prop or via context โ€” more on that in the next lesson).

The action object can carry any data you need in its payload. There's no enforced shape, but keeping type as a string constant and payload as the data container is a widely used convention that makes your code predictable:

// Descriptive action types read like a log of what happened
dispatch({ type: 'FILTER_CHANGED', payload: { genre: 'jazz' } });
dispatch({ type: 'CONCERT_DELETED', payload: concertId });
dispatch({ type: 'SORT_ORDER_TOGGLED' }); // no payload needed

<aside> โŒจ๏ธ

Hands On

Extend your traffic light from the earlier exercise. Add a second action type: 'EMERGENCY'. When dispatched, it should immediately set the light to 'red' regardless of the current state. Add a button that dispatches this action. Notice how easy it is to add new behaviour without touching any existing code โ€” just add a new case to the switch.

</aside>

Keeping Your Reducer Pure

A reducer must be a pure function: given the same state and action, it always returns the same result, with no side effects. That means no API calls, no console.log with side effects, no modifying external variables.

// โœ… Pure reducer โ€” predictable, testable
function reducer(state, action) {
  switch (action.type) {
    case 'ADD_CONCERT':
      return {
        ...state,
        concerts: [...state.concerts, action.payload],
      };
    default:
      return state;
  }
}

// โŒ Side effects don't belong here
function reducer(state, action) {
  switch (action.type) {
    case 'ADD_CONCERT':
      fetch('/api/concerts', { method: 'POST', body: JSON.stringify(action.payload) }); // โŒ
      return { ...state, concerts: [...state.concerts, action.payload] };
    default:
      return state;
  }
}

Side effects (API calls, timers, browser APIs) belong in your event handlers or in useEffect, not in the reducer.

<aside> โŒจ๏ธ

Hands On

Write a standalone reducer function (no React needed โ€” just a plain function) for a concert filter panel. It should manage this state:

const initialState = { genre: 'all', city: 'all', sortBy: 'date' };

Support three action types: 'SET_GENRE', 'SET_CITY', and 'RESET_FILTERS'. Test it by calling the function directly with different state and action combinations and logging the results. Because reducers are pure functions, you can test them like this without any React setup at all.

</aside>

Common Mistakes

Forgetting the default case. If your reducer doesn't return state for unknown actions, it returns undefined, which breaks everything. Always include default: return state.

Mutating state directly. As mentioned above โ€” always return a new object. This is especially easy to get wrong with nested state:

// โŒ This mutates the nested object
case 'UPDATE_VENUE_CAPACITY':
  state.venue.capacity = action.payload; // mutating!
  return state;

// โœ… Spread at every level you're changing
case 'UPDATE_VENUE_CAPACITY':
  return {
    ...state,
    venue: {
      ...state.venue,
      capacity: action.payload,
    },
  };

Overusing it. useReducer is a tool for managing complexity. Reaching for it when useState would do makes your code harder to read, not easier.

Best Practices

<aside> ๐ŸŽ‰

Celebration

You now know how to to make your state changes readable with useReducer. Managing complex state now becomes a whole lot easier than with useState! If a colleague can look at your reducer and understand exactly what your UI can do โ€” without reading the component โ€” you've done it right.

</aside>

Additional Resources

Videos

Reading


The HackYourFuture curriculum is licensed underย CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

CC BY-NC-SA 4.0 Icons

Built with โค๏ธ by the HackYourFuture community ยท Thank you, contributors

Found a mistake or have a suggestion? Let us know in the feedback form.