The previous lesson ended by pointing at the limit: UserProvider held a single simple value, but CicloUrbano's booking state has four pieces that change together — the list of bookings, the draft in progress, the submission phase, and the possible error — and handlers that touch three of them at once. With useState that turns into a handful of loose variables, transition logic scattered across half a dozen functions, and impossible states that nobody has explicitly forbidden. useReducer changes the approach: instead of modifying state from many places, components dispatch actions that describe what happened, and a single pure function decides how state moves from one shape to the next. In this lesson you'll see when it's time to make that leap, what a reducer actually is, how actions are designed, the complete bookingsReducer case for CicloUrbano, how to test a reducer without React, and how to combine it with useContext to share state across the whole tree.

Contents

  1. Symptoms that useState has run out of road
  2. What a reducer is, and what "pure" means
  3. The signature of useReducer
  4. Anatomy of an action, and naming conventions
  5. dispatch is stable: why that matters
  6. The complete flow, step by step
  7. CicloUrbano case study: the full bookingsReducer
  8. The third form: lazy initialization
  9. useState vs. useReducer: a decision table
  10. Testing a reducer without React
  11. useReducer + useContext: shared state

  1. Symptoms that useState has run out of road

useReducer isn't "advanced useState" or an automatic upgrade. It's a tool for a specific situation, and here's the list of symptoms that announce it.

Symptom 1: many related useState calls

// CicloUrbano's bookings panel with scattered useState
const [bookings, setBookings] = useState(initialBookings);
const [draft, setDraft] = useState(INITIAL_DATA);
const [submitState, setSubmitState] = useState('idle');
const [error, setError] = useState(null);

Four variables that are never used separately: every real operation touches at least two of them.

Symptom 2: handlers that touch three pieces of state at once

function handleConfirm(bookingId) {
  setSubmitState('submitting');
  setError(null);
  setBookings((previous) =>
    previous.map((b) => (b.id === bookingId ? { ...b, status: 'confirmada' } : b))
  );
  setDraft(INITIAL_DATA);
  setSubmitState('success');
}

The problem isn't the length: it's that the business rule is spread across four calls, and if tomorrow confirming a booking also has to free up a dock at the station, someone has to remember to add the fifth call. In every handler.

Symptom 3: impossible states that nobody forbids

Nothing stops submitState from being 'submitting' while error holds text at the same time. The structure allows it, and only discipline prevents it. With a reducer, valid transitions are written once, in one place, and whatever isn't written can't happen.

Symptom 4: the same logic repeated across several handlers

Cancelling, expiring, and rejecting a booking do almost the same thing. With useState you end up copying the map three times; with a reducer, the three actions land in a switch where the duplication jumps out and gets factored away.

Rule of thumb: if a state change needs more than two updater calls, or if two state variables never change separately, try useReducer.

  1. What a reducer is, and what "pure" means

A reducer is a function that receives the current state and an action, and returns the new state: (previousState, action) => newState

The name comes from arrays' reduce method, which also takes an accumulator and an element and returns the next accumulator. A React reducer does the same thing with the history of actions: if you applied every action from the beginning, you'd end up with the current state.

// A minimal reducer, just to see the shape
function hoursReducer(hours, action) {
  switch (action.type) {
    case 'hour_added':
      return hours + 1;
    case 'hour_removed':
      return Math.max(1, hours - 1);
    case 'hours_set':
      return action.hours;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

A reducer must be pure, and here "pure" means exactly three things:

Requirement What it implies What you CAN'T do inside
Deterministic The same arguments always produce the same result Math.random(), Date.now(), crypto.randomUUID()
No side effects It doesn't touch anything outside itself fetch, localStorage, console.log with counters, mutating outside variables
Doesn't mutate its arguments The previous state is copied, never modified state.bookings.push(x), state.error = 'something'
// ❌ IMPURE: date and randomness inside the reducer
case 'booking_created':
  return {
    ...state,
    bookings: [...state.bookings, {
      id: `res-${crypto.randomUUID().slice(0, 8)}`,   // ⚠️ not deterministic
      createdAt: new Date().toISOString()              // ⚠️ not deterministic
    }]
  };

// ✅ PURE: non-deterministic values arrive ALREADY computed inside the action
case 'booking_created':
  return { ...state, bookings: [...state.bookings, action.booking] };

The rule always resolves the same way: whatever is non-deterministic gets computed in the handler and travels inside the action. It's the same criterion from 04-05, generating the incident id outside of render.

Why insist on this so much? For three practical reasons: React can call the reducer twice under StrictMode to catch impurities; a pure function can be tested without mounting anything (section 10); and a pure reducer makes state reproducible, which is what makes debugging tools that rewind the action history possible.

  1. The signature of useReducer

const [state, dispatch] = useReducer(reducer, initialState);
Argument / value What it is
reducer The (previousState, action) => newState function. Declared outside the component
initialState The state of the first render
state The state of the render in progress. Read only, never assigned
dispatch Function that sends an action to the reducer and triggers a render

The symmetry with useState is deliberate: both return a pair [value, way to change it], and both follow the snapshot rule from 05-01. Calling dispatch doesn't change state in the render in progress: it queues the action, React processes the queue, and the new value shows up on the next render.

function handleAddHour() {
  dispatch({ type: 'hour_added' });
  console.log(state.hours);   // the OLD value: same snapshot as with useState
}

The reducer is declared outside the component. It needs nothing from the component's scope — it receives everything it needs as arguments — so keeping it inside would only recreate it on every render and make it harder to test.

  1. Anatomy of an action, and naming conventions

An action is a plain object that describes something that happened. By convention it carries an identifying field plus whatever data it needs:

{ type: 'booking_confirmed', bookingId: 'res-01' }
{ type: 'draft_updated', field: 'hours', value: 4 }
{ type: 'submit_failed', message: 'The server responded 503' }

Across the ecosystem this field is called type (and in Redux it's mandatory that it's called that, as you'll see in 07-04). The extra data is generically called payload: you can either place it loose — as above — or grouped under payload, as long as you stay consistent.

The golden rule of naming

Name actions after what happened, not after what has to be done.

❌ Imperative name ✅ Declarative name Why it's better
set_state booking_confirmed States what happened; the reducer decides the consequences
set_loading submit_started One action can change several pieces at once
update_bookings booking_cancelled Reads like a record of the business event
clear_all form_reset Understandable without reading the reducer

The difference looks cosmetic, and it isn't. With set_state the component decides how the state changes, and the logic ends up scattered again: it's useState with extra ceremony. With booking_confirmed, the component only reports the fact and the reducer concentrates every consequence. If tomorrow confirming also has to clear the draft and log the time, you touch a single line of the reducer and every place that dispatches that action is instantly up to date.

A well-named list of actions reads like the chronicle of everything that can happen in the app:

booking_created · booking_confirmed · booking_cancelled
draft_updated · draft_reset
submit_started · submit_completed · submit_failed

  1. dispatch is stable: why that matters

Just like useState's updaters (05-01), React guarantees that dispatch is the same function across every render. It's never recreated. Three direct consequences:

  • It doesn't belong in an effect's dependencies (05-02). You can dispatch from inside an effect without triggering re-runs.
  • You don't need to wrap it in useCallback (08-03) to pass it to a memoized child: it's already stable.
  • It can go into a context at no cost: the context value doesn't change because of it (section 11).

This has a very valuable second-order effect. Compare these two effects:

// With useState: the effect needs the current state, so it depends on it
useEffect(() => {
  const id = setInterval(() => {
    setBookings((previous) => previous.map(expireIfDue));   // functional form required
  }, 60000);
  return () => clearInterval(id);
}, []);

// With useReducer: the effect doesn't need to know ANYTHING about the state
useEffect(() => {
  const id = setInterval(() => {
    dispatch({ type: 'bookings_expiry_checked' });
  }, 60000);
  return () => clearInterval(id);
}, []);   // not a single dependency, and no tricks

The second effect is cleaner because it separates the trigger from the consequence: the timer only announces that a minute has passed; what that means for the bookings is up to the reducer. It's the most effective way to simplify complicated effects.

  1. The complete flow, step by step

flowchart TD
    A["The user clicks 'Confirm'"] --> B["Handler: handleConfirm(id)"]
    B --> C["dispatch({ type: 'booking_confirmed', bookingId: id })"]
    C --> D["React queues the action"]
    D --> E["bookingsReducer(previousState, action)"]
    E --> F["Returns a NEW STATE OBJECT"]
    F --> G["React compares it and schedules a render"]
    G --> H["The component renders with the new state"]
    H --> I["Screen updated"]
    style C fill:#e0f2fe
    style E fill:#fde68a
    style I fill:#dcfce7

What makes this flow valuable is the separation of responsibilities:

Piece Responsibility What it DOESN'T do
The component Detect the interaction and describe the fact Decide how the state ends up
The action Carry the fact and its data Contain logic
The reducer Apply every transition rule Touch the DOM, the network, or the clock
React Render with the resulting state Interpret the actions

  1. CicloUrbano case study: the full bookingsReducer

Time for the real case. The state manages the four pieces from section 1.

// src/reducers/bookings.js
import { INITIAL_DATA } from '../components/BookingForm.jsx';

export const INITIAL_BOOKINGS_STATE = {
  bookings: [],                // list of Booking
  draft: INITIAL_DATA,         // { bicicletaId, startDate, hours, terms }
  submitState: 'idle',         // 'idle' | 'submitting' | 'success' | 'error'
  error: null                  // failure message, or null
};

/**
 * Reducer for CicloUrbano's bookings panel.
 * PURE function: (previousState, action) => newState
 */
export function bookingsReducer(state, action) {
  switch (action.type) {
    case 'draft_updated':
      return {
        ...state,
        draft: { ...state.draft, [action.field]: action.value },
        error: null   // typing clears the previous error
      };

    case 'draft_reset':
      return { ...state, draft: INITIAL_DATA, error: null };

    case 'submit_started':
      return { ...state, submitState: 'submitting', error: null };

    case 'booking_created':
      return {
        ...state,
        bookings: [...state.bookings, action.booking],   // the booking arrives already built
        draft: INITIAL_DATA,
        submitState: 'success',
        error: null
      };

    case 'submit_failed':
      return { ...state, submitState: 'error', error: action.message };

    case 'booking_confirmed':
      return {
        ...state,
        bookings: state.bookings.map((booking) =>
          booking.id === action.bookingId ? { ...booking, status: 'confirmada' } : booking
        )
      };

    case 'booking_cancelled':
      return {
        ...state,
        bookings: state.bookings.map((booking) =>
          booking.id === action.bookingId
            ? { ...booking, status: 'cancelada', cancelledAt: action.cancelledAt }
            : booking
        )
      };

    case 'panel_reset':
      return { ...INITIAL_BOOKINGS_STATE, bookings: state.bookings };

    default:
      throw new Error(`bookingsReducer: unknown action "${action.type}"`);
  }
}

Notes on specific decisions in this reducer:

  • Every case returns a new object built on ...state. Nothing is ever mutated. It's the same immutability recipe from 05-01, now concentrated in a single file.
  • booking_created receives the booking already built. The id res-${crypto.randomUUID().slice(0, 8)} and the date are generated in the handler, because they're non-deterministic.
  • booking_cancelled receives action.cancelledAt for the same reason: new Date().toISOString() can't live inside the reducer.
  • A single action changes several pieces at once. booking_created touches the list, clears the draft, marks the submission as done, and clears the error: four coherent changes, impossible to get out of sync because they happen in the same expression.
  • panel_reset keeps the bookings and resets everything else. The initial state gets reused, without repeating literals.
  • The default throws. That's deliberate: a silent return state would turn a typo like 'booking_creatd' into an invisible failure that shows up as "the button does nothing." With the throw, the failure surfaces immediately and — if you've set up an ErrorBoundary (04-05) — with a decent fallback interface.

The component that uses it

// src/components/BookingsPanel.jsx
import { useReducer } from 'react';
import { bookingsReducer, INITIAL_BOOKINGS_STATE } from '../reducers/bookings.js';
import { validateBooking } from '../utils/validateBooking.js';
import Notice from './Notice.jsx';
import styles from './BookingsPanel.module.css';

/**
 * CicloUrbano's bookings panel.
 * Props:
 *  - bikes (array of Bike, optional, defaults to [])
 *  - userId (string, optional, defaults to 'usr-01')
 */
function BookingsPanel({ bikes = [], userId = 'usr-01' }) {
  const [state, dispatch] = useReducer(bookingsReducer, INITIAL_BOOKINGS_STATE);
  const { bookings, draft, submitState, error } = state;

  // DERIVED: not state (05-01)
  const errors = validateBooking(draft, bikes);
  const canSubmit = Object.keys(errors).length === 0 && submitState !== 'submitting';

  function handleFieldChange(field, value) {
    dispatch({ type: 'draft_updated', field, value });
  }

  async function handleSubmit(event) {
    event.preventDefault();
    if (!canSubmit) return;

    // Non-deterministic values are computed HERE, not in the reducer
    const booking = {
      id: `res-${crypto.randomUUID().slice(0, 8)}`,
      bicicletaId: draft.bicicletaId,
      user: userId,
      startDate: draft.startDate,
      hours: draft.hours,
      status: 'activa'
    };

    dispatch({ type: 'submit_started' });
    try {
      await submitBooking(booking);
      dispatch({ type: 'booking_created', booking });
    } catch (failure) {
      dispatch({ type: 'submit_failed', message: failure.message });
    }
  }

  function handleConfirm(bookingId) {
    dispatch({ type: 'booking_confirmed', bookingId });
  }

  function handleCancel(bookingId) {
    dispatch({
      type: 'booking_cancelled',
      bookingId,
      cancelledAt: new Date().toISOString()
    });
  }

  return (
    <section className={styles.panel}>
      <form noValidate onSubmit={handleSubmit}>
        {/* controlled fields calling handleFieldChange (03-04) */}
        <button type="submit" disabled={!canSubmit}>
          {submitState === 'submitting' ? 'Submitting…' : 'Create booking'}
        </button>
      </form>

      {submitState === 'error' && <Notice tone="error">{error}</Notice>}
      {submitState === 'success' && <Notice tone="success">Booking created successfully.</Notice>}

      <ul>
        {bookings.map((booking) => (
          <li key={booking.id}>
            {booking.id} · {booking.hours} h · {booking.status}
            <button type="button" onClick={() => handleConfirm(booking.id)}>Confirm</button>
            <button type="button" onClick={() => handleCancel(booking.id)}>Cancel</button>
          </li>
        ))}
      </ul>
    </section>
  );
}

export default BookingsPanel;

Compare this component with the four-useState version from section 1. The handlers went from coordinating several calls to announcing a fact in one line. All the transition logic lives in a separate file that reads top to bottom like the panel's operating manual. And handleSubmit makes the split crystal clear: what's asynchronous and non-deterministic lives in the component; what happens to the state lives in the reducer.

  1. The third form: lazy initialization

useReducer accepts a third argument: a function that computes the initial state from the second argument.

const [state, dispatch] = useReducer(bookingsReducer, savedBookings, createInitialState);
// src/reducers/bookings.js
export function createInitialState(savedBookings) {
  return {
    ...INITIAL_BOOKINGS_STATE,
    bookings: savedBookings.filter((booking) => booking.status !== 'cancelada')
  };
}

It's the same idea as useState's lazy initialization (05-01), and it brings the same benefit: the computation runs only once, on the first render, instead of on every one. With the added bonus that the initializer function, living outside the component, can also be tested separately and reused in a reset action:

case 'panel_reset':
  return createInitialState(action.savedBookings ?? []);

  1. useState vs. useReducer: a decision table

Criterion useState useReducer
Number of state pieces One, or several independent ones Several that change together
Complexity of transitions The new value comes from one expression Rules, conditions, several pieces per change
Where the logic lives Spread across the handlers Concentrated in one function
Readability of handlers Gets worse as it grows One line per handler
Testability Requires mounting the component Pure function: tested on its own
Traceability You have to find every caller of each set The action list documents what can happen
Impossible states Possible without discipline Hard: only what the reducer writes can exist
Effect dependencies The state usually goes in [deps] dispatch is stable: cleaner effects
Reading curve Immediate You have to check the reducer to understand an action's effect
Lines for a counter 1 ~10

Two warnings so you don't overcorrect:

  • Don't convert everything to useReducer. For a collapsed/expanded panel or a search box's text, useState is shorter and clearer. A reducer for a boolean is pure ceremony.
  • You can mix them in the same component. The usual pattern is one useReducer for the complex matter and a few useState calls for local, trivial bits.

  1. Testing a reducer without React

This is one of the biggest practical advantages, and it's worth seeing even though testing is Module 9. A reducer is a pure function: it needs no browser, no DOM, no rendering anything.

// src/reducers/bookings.test.js  (Vitest/Jest syntax — Module 9)
import { describe, it, expect } from 'vitest';
import { bookingsReducer, INITIAL_BOOKINGS_STATE } from './bookings.js';

describe('bookingsReducer', () => {
  it('creates a booking, clears the draft, and marks the submission as done', () => {
    const booking = {
      id: 'res-02',
      bicicletaId: 'bici-001',
      user: 'usr-01',
      startDate: '2026-05-05T10:00',
      hours: 3,
      status: 'activa'
    };

    const result = bookingsReducer(
      { ...INITIAL_BOOKINGS_STATE, submitState: 'submitting' },
      { type: 'booking_created', booking }
    );

    expect(result.bookings).toHaveLength(1);
    expect(result.submitState).toBe('success');
    expect(result.draft.bicicletaId).toBe('');
    expect(result.error).toBeNull();
  });

  it('does not mutate the state it receives', () => {
    const previous = { ...INITIAL_BOOKINGS_STATE, bookings: [] };
    bookingsReducer(previous, { type: 'booking_created', booking: { id: 'res-03' } });

    expect(previous.bookings).toHaveLength(0);   // the original is untouched
  });

  it('throws on an unknown action', () => {
    expect(() => bookingsReducer(INITIAL_BOOKINGS_STATE, { type: 'invented' })).toThrow();
  });
});

Without mounting a single component you've verified three business rules and immutability. With the logic spread across handlers inside the component, each of these checks would require rendering, simulating clicks, and waiting. It's the main reason large teams extract state logic into reducers: pure code is cheap to test. The tooling details arrive in 09-02.

  1. useReducer + useContext: shared state

Combining this lesson with the previous one produces a very powerful pattern: the complex state lives in a reducer, the reducer lives in a provider, and any component in the tree can read the state and dispatch actions without receiving a single prop.

// src/contexts/BookingsContext.jsx
import { createContext, useContext, useReducer } from 'react';
import { bookingsReducer, INITIAL_BOOKINGS_STATE } from '../reducers/bookings.js';

const BookingsContext = createContext(null);

/**
 * Provider for CicloUrbano's bookings state.
 * Props:
 *  - children (content)
 */
export function BookingsProvider({ children }) {
  const [state, dispatch] = useReducer(bookingsReducer, INITIAL_BOOKINGS_STATE);

  return (
    <BookingsContext value={{ state, dispatch }}>
      {children}
    </BookingsContext>
  );
}

export function useBookings() {
  const context = useContext(BookingsContext);
  if (context === null) {
    throw new Error('useBookings must be used inside <BookingsProvider>');
  }
  return context;
}

And any component, at any depth:

// src/components/ActiveBookingsCounter.jsx
import { useBookings } from '../contexts/BookingsContext.jsx';

function ActiveBookingsCounter() {
  const { state } = useBookings();
  const active = state.bookings.filter((booking) => booking.status === 'activa').length;

  return <span>Active bookings: {active}</span>;
}
// src/components/CancelBookingButton.jsx
import { useBookings } from '../contexts/BookingsContext.jsx';

function CancelBookingButton({ bookingId }) {
  const { dispatch } = useBookings();

  return (
    <button
      type="button"
      onClick={() =>
        dispatch({ type: 'booking_cancelled', bookingId, cancelledAt: new Date().toISOString() })
      }
    >
      Cancel
    </button>
  );
}
flowchart TD
    PR["&lt;BookingsProvider&gt;<br/>useReducer(bookingsReducer)"] --> LAY["Layout"]
    LAY --> HDR["Header"]
    HDR --> CNT["ActiveBookingsCounter<br/>reads state"]
    LAY --> PAN["BookingsPanel"]
    PAN --> BTN["CancelBookingButton<br/>dispatches actions"]
    BTN -. "dispatch" .-> PR
    PR -. "state" .-> CNT
    style PR fill:#fde68a
    style CNT fill:#dcfce7
    style BTN fill:#e0f2fe

If you've heard of Redux, you've just written its whole mental model: centralized state, actions that describe facts, and pure reducers that compute the next state. Redux adds tooling around that — a single store outside React, middleware for async work, debugging extensions that rewind actions, and utilities like Redux Toolkit — but the core is exactly this. The full comparison, and when each option pays off, is covered in 07-01 and 07-03. What matters here is that you already understand the mechanism, so Redux won't feel like a mystery — just a packaging of something familiar.

A performance note, in the same vein as 05-04's: putting { state, dispatch } into a context means any state change re-renders every consumer, including the ones that only dispatch and never read. The usual fix — splitting state and dispatch into two separate contexts — belongs to 07-02.

Common Mistakes and Tips

  • Mutating state inside the reducer. state.bookings.push(newOne); return state; doesn't re-render: the reference is the same. Always copy.
  • Putting non-deterministic code or effects in the reducer. Date.now(), crypto.randomUUID(), fetch, or localStorage break purity. Compute it in the handler and pass it in the action.
  • A default that silently returns the state. Turns a typo into a button that does nothing. Throw an error with the type you received.
  • Naming actions imperatively (set_error, set_bookings). The logic goes back into the component and you lose the whole advantage.
  • Declaring the reducer inside the component. It gets recreated on every render, can't be tested on its own, and implies it depends on the component's scope, which it shouldn't.
  • Converting simple state to useReducer. Ten lines for a boolean don't improve anything.
  • Reading the state right after dispatching. The snapshot rule from 05-01 still applies: dispatch doesn't change state in the render in progress.
  • Forgetting ...state when returning. return { bookings: [...] }; wipes out the draft, the submit state, and the error in one stroke.
  • Tip: write the list of actions first, before the reducer. If that list reads like the chronicle of everything that can happen on screen, the design is good.
  • Tip: if a case repeats almost the same thing as another, extract a pure helper function and call it from both. A reducer can lean on other pure functions without any problem.

Exercises

Exercise 1. This CicloUrbano reducer has four bugs. Find them, explain what's wrong with each one, and write the corrected version.

function fleetReducer(state, action) {
  switch (action.type) {
    case 'bike_added':
      state.bikes.push({ id: `bici-${Math.random()}`, ...action.data });
      return state;

    case 'bike_sent_to_workshop':
      return {
        bikes: state.bikes.map((b) =>
          b.id === action.id ? { ...b, status: 'mantenimiento' } : b
        )
      };

    case 'fleet_reloaded':
      fetch('/api/bicicletas').then((r) => r.json()).then((d) => (state.bikes = d));
      return state;

    default:
      return state;
  }
}

Exercise 2. Convert this CicloUrbano component to useReducer. Define the initial state, write the full reducer with declarative action names, and rewrite the handlers.

function BookingSelector({ bikes }) {
  const [bikeId, setBikeId] = useState(null);
  const [hours, setHours] = useState(2);
  const [confirmed, setConfirmed] = useState(false);
  const [error, setError] = useState(null);

  function handleSelect(id) {
    setBikeId(id);
    setHours(2);
    setConfirmed(false);
    setError(null);
  }

  function handleHoursChange(newHours) {
    if (newHours < 1 || newHours > 24) {
      setError('Bookings run from 1 to 24 hours.');
      return;
    }
    setHours(newHours);
    setError(null);
    setConfirmed(false);
  }

  function handleConfirm() {
    if (!bikeId) {
      setError('Choose a bike before confirming.');
      return;
    }
    setConfirmed(true);
    setError(null);
  }
  …
}

Exercise 3. Write three tests for the bookingsReducer from section 7, without rendering anything: that draft_updated changes only the indicated field and keeps the rest intact; that submit_failed stores the message and leaves the bookings untouched; and that panel_reset keeps the booking list but clears the draft and the error.

Solutions

Solution 1.

The four bugs:

  1. bike_added mutates the state with push and returns the same reference. React sees no change and doesn't re-render.
  2. Math.random() inside the reducer breaks determinism. The id has to be generated in the handler and arrive inside the action.
  3. bike_sent_to_workshop doesn't copy the rest of the state. It returns an object with only bikes, so every other piece (filters, selection, error) is lost.
  4. fleet_reloaded does a fetch and mutates the state in the then. A side effect inside the reducer, and an asynchronous one at that: by the time the response arrives, that state object was discarded long ago. Data fetching belongs in an effect (05-02) or in the handler, and the result gets dispatched as an action.

Extra: the default that silently returns the state hides typos in the action types.

export const INITIAL_FLEET_STATE = { bikes: [], loading: false, error: null };

export function fleetReducer(state, action) {
  switch (action.type) {
    case 'bike_added':
      // The bike arrives already built, with its id generated outside
      return { ...state, bikes: [...state.bikes, action.bike] };

    case 'bike_sent_to_workshop':
      return {
        ...state,
        bikes: state.bikes.map((bike) =>
          bike.id === action.id ? { ...bike, status: 'mantenimiento' } : bike
        )
      };

    case 'reload_started':
      return { ...state, loading: true, error: null };

    case 'fleet_reloaded':
      // The component does the fetch; only the data arrives here
      return { ...state, bikes: action.bikes, loading: false, error: null };

    case 'reload_failed':
      return { ...state, loading: false, error: action.message };

    default:
      throw new Error(`fleetReducer: unknown action "${action.type}"`);
  }
}

And the handler, with the async and non-deterministic parts where they belong:

function handleAdd(data) {
  dispatch({
    type: 'bike_added',
    bike: { id: `bici-${crypto.randomUUID().slice(0, 8)}`, ...data }
  });
}

async function handleReload() {
  dispatch({ type: 'reload_started' });
  try {
    const response = await fetch('/api/bicicletas');
    if (!response.ok) throw new Error(`The server responded ${response.status}`);
    dispatch({ type: 'fleet_reloaded', bikes: await response.json() });
  } catch (failure) {
    dispatch({ type: 'reload_failed', message: failure.message });
  }
}

Solution 2.

// src/reducers/bookingSelector.js
export const INITIAL_SELECTOR_STATE = {
  bikeId: null,
  hours: 2,
  confirmed: false,
  error: null
};

const MIN_HOURS = 1;
const MAX_HOURS = 24;

export function selectorReducer(state, action) {
  switch (action.type) {
    case 'bike_chosen':
      // Choosing a bike resets the whole booking in progress
      return { ...INITIAL_SELECTOR_STATE, bikeId: action.id };

    case 'hours_changed':
      if (action.hours < MIN_HOURS || action.hours > MAX_HOURS) {
        return {
          ...state,
          error: `Bookings run from ${MIN_HOURS} to ${MAX_HOURS} hours.`
        };
      }
      return { ...state, hours: action.hours, error: null, confirmed: false };

    case 'booking_confirmed':
      if (!state.bikeId) {
        return { ...state, error: 'Choose a bike before confirming.' };
      }
      return { ...state, confirmed: true, error: null };

    default:
      throw new Error(`selectorReducer: unknown action "${action.type}"`);
  }
}
// src/components/BookingSelector.jsx
import { useReducer } from 'react';
import { selectorReducer, INITIAL_SELECTOR_STATE } from '../reducers/bookingSelector.js';

function BookingSelector({ bikes = [] }) {
  const [state, dispatch] = useReducer(selectorReducer, INITIAL_SELECTOR_STATE);
  const { bikeId, hours, confirmed, error } = state;

  function handleSelect(id) {
    dispatch({ type: 'bike_chosen', id });
  }

  function handleHoursChange(newHours) {
    dispatch({ type: 'hours_changed', hours: newHours });
  }

  function handleConfirm() {
    dispatch({ type: 'booking_confirmed' });
  }
  …
}

What's been gained: the three handlers are one line each; the rules — the 1-to-24-hour limits, that choosing a bike resets the booking, that confirming without a bike is an error — are written in a single file and read in sequence; the limits are named constants instead of loose numbers; and bike_chosen reuses INITIAL_SELECTOR_STATE instead of repeating four assignments, so if a fifth piece gets added to the state tomorrow, the reset picks it up without touching anything.

Solution 3.

import { describe, it, expect } from 'vitest';
import { bookingsReducer, INITIAL_BOOKINGS_STATE } from './bookings.js';

describe('bookingsReducer', () => {
  it('draft_updated changes only the indicated field', () => {
    const previous = {
      ...INITIAL_BOOKINGS_STATE,
      draft: { bicicletaId: 'bici-001', startDate: '2026-05-04T09:00', hours: 2, terms: false }
    };

    const result = bookingsReducer(previous, {
      type: 'draft_updated',
      field: 'hours',
      value: 5
    });

    expect(result.draft.hours).toBe(5);
    expect(result.draft.bicicletaId).toBe('bici-001');   // the rest untouched
    expect(result.draft.startDate).toBe('2026-05-04T09:00');
    expect(previous.draft.hours).toBe(2);                 // no mutation
  });

  it('submit_failed stores the message and does not touch the bookings', () => {
    const previous = {
      ...INITIAL_BOOKINGS_STATE,
      bookings: [{ id: 'res-01', hours: 2, status: 'activa' }],
      submitState: 'submitting'
    };

    const result = bookingsReducer(previous, {
      type: 'submit_failed',
      message: 'The server responded 503'
    });

    expect(result.submitState).toBe('error');
    expect(result.error).toBe('The server responded 503');
    expect(result.bookings).toBe(previous.bookings);   // same reference: not copied
  });

  it('panel_reset keeps the bookings and clears everything else', () => {
    const previous = {
      bookings: [{ id: 'res-01', hours: 2, status: 'activa' }],
      draft: { bicicletaId: 'bici-003', startDate: '2026-05-06T08:00', hours: 6, terms: true },
      submitState: 'error',
      error: 'Previous failure'
    };

    const result = bookingsReducer(previous, { type: 'panel_reset' });

    expect(result.bookings).toHaveLength(1);
    expect(result.draft.bicicletaId).toBe('');
    expect(result.submitState).toBe('idle');
    expect(result.error).toBeNull();
  });
});

Look at the second test: expect(result.bookings).toBe(previous.bookings) checks identity, not equality. That's a deliberate assertion: submit_failed shouldn't copy the list, because copying it unnecessarily would create a new reference and force every memoized component that depends on it to re-render (Module 8). A well-written reducer preserves the references of whatever hasn't changed.

Conclusion

useReducer is the answer once state stops being a single piece of data and becomes a system: several pieces changing together, transitions governed by rules, and handlers that turn unreadable. The core idea is a shift in responsibility: the component no longer decides how the state ends up, it just dispatches actions that describe what happened, and a pure function — the reducer — concentrates every rule. You've seen the useReducer(reducer, initialState) signature and its third form with lazy initialization; the anatomy of actions, and why naming them after the fact (booking_confirmed) rather than the operation (set_state) is what keeps the logic from scattering again; that dispatch is stable, which simplifies effect dependencies and memoization; the full bookingsReducer for CicloUrbano with its switch, its immutability, and its default that throws; and two advantages you'll cash in on every day: a reducer can be tested without React, because it's pure, and it combines with useContext to give the whole tree access to the state and the actions. That last pattern is, quite literally, Redux's mental model, which Module 7 will pick back up with a full comparison.

With this you've covered React's five fundamental hooks: useState, useEffect, useRef, useContext, and useReducer. But one piece remains, the one that gives the whole design of hooks its purpose, the one 04-04 flagged as their historical reason for existing: reusing stateful logic without wrappers. The debounced search box from the 05-02 exercise, the subscription to the connection-status event, the availability timer, the state synced with localStorage for the theme… all of these are pieces that repeat across different components and that today you'd have to copy and paste. The next lesson turns that logic into reusable functions that are called just like React's own hooks, because they are hooks. The next lesson is Custom Hooks.

React Course

Module 1: Getting Started with React

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved