The UI is finished and does nothing. The bikes come from a file, the filter is forgotten on reload, the form doesn't submit, there's no session, and ProtectedRoute lets anyone through. This lesson wires everything up: by the end, CicloUrbano will be a real application, with data that travels over the network, gets cached, invalidated, and shown with its loading and error states in the right place.

The work is organized from the inside out. First, a data access layer that knows nothing about React and centralizes the base URL, headers, HTTP error handling, and timeouts. On top of that, TanStack Query, with its hierarchical keys, its carefully chosen defaults, and the project's mutations — including optimistic updates with their rollback. Alongside it, Redux Toolkit for what really is client state, context for theme and notices, and the URL for the filter. And above all, the question that decides an application's perceived quality: when something fails, who shows the error.

Nothing that follows is conceptually new; all of it was explained in Module 7. What's new is applying it to a complete project, where decisions rub up against each other and the conflicts have to be resolved.

Contents

  1. The definitive table: what data lives where
  2. The data access layer: src/api/client.js
  3. Resource-specific functions
  4. Why this layer knows nothing about React
  5. queryClient.js and its defaults
  6. The key factory
  7. The project's query hooks
  8. Connecting the catalogue: from toggles to real states
  9. The complete mutation: creating a booking
  10. Optimistic updates: confirming and cancelling
  11. Redux: the session
  12. Redux: the catalogue, and what doesn't belong in Redux
  13. Context: theme and notices
  14. The filter in the URL with useSearchParams
  15. Protected routes wired to the real session
  16. End-to-end error handling
  17. The final main.jsx
  18. The complete booking flow
  19. Manual walkthrough check

  1. The definitive table: what data lives where

The record from 11-01 set the criteria; this is its application, piece of data by piece of data. It's the table you consult every time a new piece of state shows up, the one that heads off the usual argument.

Data Where it lives Why there and not somewhere else
Bike list TanStack Query Lives on the server; needs caching, revalidation, and invalidation after mutating
A bike's detail page TanStack Query Same, with its own detail key
Stations and their detail TanStack Query Same. They change very little: high staleTime
User's bookings TanStack Query Same, with a key that depends on the user
Signed-in user Redux (sessionSlice) Read by the header, the protected routes, and the booking form. It isn't a copy of a remote resource: it's the state of this session
Search term Redux (catalogueSlice) Written by the search box and read by the list; it survives navigating to a detail page and back
Sort order Redux (catalogueSlice) Same, and it's a user preference within the session
Type filter URL (?tipo=) Must be shareable via link and survive a reload (H2)
Station's active tab URL (route segment) Same
Light/dark theme Context + localStorage Changes rarely, needed by the whole tree
Temporary notices Context Emitted by any screen and rendered by the frame
Confirmation modal open Local useState No one outside the screen needs to know it
Form draft Local useState Discarded on exit; storing it in Redux would only add actions
A mutation's submit state TanStack Query (isPending) Provided by the mutation; duplicating it in useState causes desync

The two rows that get it wrong most often in real projects are the first and the last. Putting the bike list in Redux forces you to reimplement caching, deduplication, and revalidation (07-06). Duplicating isPending in a useState produces the classic button that spins forever because someone forgot to set it back to false on the error branch.

flowchart TD
    subgraph SERVER["Server state · TanStack Query"]
        Q1["bikes"]
        Q2["stations"]
        Q3["bookings"]
    end
    subgraph CLIENT["Client state · Redux Toolkit"]
        R1["sessionSlice: user"]
        R2["catalogueSlice: searchTerm, sort"]
    end
    subgraph URL["URL state · React Router"]
        U1["?tipo=electrica"]
        U2["/estaciones/est-01/incidencias"]
    end
    subgraph CONTEXT["Global UI · Context"]
        C1["theme"]
        C2["notices"]
    end
    subgraph LOCAL["Local · useState"]
        L1["modal open"]
        L2["form draft"]
    end
    SCREEN["A screen"] --> SERVER
    SCREEN --> CLIENT
    SCREEN --> URL
    SCREEN --> CONTEXT
    SCREEN --> LOCAL

  1. The data access layer: src/api/client.js

Before writing a single hook, you have to decide how the app talks to the network. Scattering fetch calls across components means repeating the base URL, the Content-Type, the response.ok check, and the JSON.parse in fifteen places — and discovering on deployment day that one of them forgot to check the status.

// src/api/client.js
import { API_URL } from '../config.js';

const TIMEOUT = 10_000;   // 10 s: past that, the network is given up on

/**
 * Data-layer error. Carries the HTTP status so whoever receives it
 * can decide: a 404 isn't the same as a 500, nor the same as a network failure.
 */
export class ApiError extends Error {
  constructor(message, { status = null, url = null, body = null } = {}) {
    super(message);
    this.name = 'ApiError';
    this.status = status;
    this.url = url;
    this.body = body;
  }

  get isNotFound() {
    return this.status === 404;
  }

  get isNetwork() {
    return this.status === null;   // there was never a response
  }

  get isServer() {
    return this.status !== null && this.status >= 500;
  }
}

/**
 * Single fetch wrapper. Every request in the project goes through here.
 */
export async function request(path, options = {}) {
  const { method = 'GET', body, signal, ...rest } = options;

  // Its own timeout: fetch doesn't come with one, and a hung request
  // leaves the UI "loading" forever
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), TIMEOUT);

  // If the caller brings its own signal (TanStack Query passes one), they're combined
  const finalSignal = signal
    ? AbortSignal.any([signal, controller.signal])
    : controller.signal;

  const url = `${API_URL}${path}`;

  try {
    const response = await fetch(url, {
      method,
      signal: finalSignal,
      headers: {
        Accept: 'application/json',
        ...(body ? { 'Content-Type': 'application/json' } : {}),
        ...rest.headers
      },
      ...(body ? { body: JSON.stringify(body) } : {}),
      ...rest
    });

    if (!response.ok) {
      // Try to read the error body, but its absence must not break anything
      let detail = null;
      try {
        detail = await response.json();
      } catch {
        detail = null;
      }

      throw new ApiError(messageForStatus(response.status), {
        status: response.status,
        url,
        body: detail
      });
    }

    // 204 No Content: there's no body to parse
    if (response.status === 204) return null;

    return await response.json();
  } catch (error) {
    if (error instanceof ApiError) throw error;

    if (error.name === 'AbortError') {
      throw new ApiError('The request took too long.', { url });
    }

    // fetch TypeError = no response at all: no network, DNS, CORS…
    throw new ApiError('Could not connect to the server.', { url });
  } finally {
    clearTimeout(timer);
  }
}

function messageForStatus(status) {
  if (status === 404) return 'The requested resource does not exist.';
  if (status === 401) return 'The session has expired.';
  if (status === 403) return 'You do not have permission for this operation.';
  if (status >= 500) return 'The server is not responding correctly.';
  return 'The request could not be completed.';
}

What each part solves, because each one comes from a real failure:

Part Problem it avoids
API_URL from config.js Changing environments without touching fifteen files
ApiError with status Being able to distinguish a 404 from a 500 from a network failure further up, in the UI
AbortController with a timer The hung request that leaves the skeleton spinning indefinitely
AbortSignal.any Combining the timeout with the cancellation TanStack Query sends on unmount
Checking response.ok fetch does not throw on a 500: without this, a server error would arrive as valid data
try/catch when reading the error body An error response without JSON must not produce a second error more confusing than the first
The 204 case response.json() throws on an empty body
messageForStatus Clear, understandable messages, kept in one place
finally with clearTimeout A timer that outlives the request

  1. Resource-specific functions

On top of the wrapper, one function per operation. They're the only ones that know the API's routes.

// src/api/bikes.js
import { request } from './client.js';

export function getBikes({ type, signal } = {}) {
  // json-server filters by field with a query parameter
  const params = new URLSearchParams();
  if (type && type !== 'todos') params.set('tipo', type);

  const query = params.toString();
  return request(`/bicicletas${query ? `?${query}` : ''}`, { signal });
}

export function getBike(id, { signal } = {}) {
  return request(`/bicicletas/${id}`, { signal });
}

export function updateBikeStatus(id, status) {
  return request(`/bicicletas/${id}`, { method: 'PATCH', body: { status } });
}
// src/api/bookings.js
import { request } from './client.js';

export function getBookings({ userId, signal } = {}) {
  const path = userId ? `/reservas?user=${userId}` : '/reservas';
  return request(path, { signal });
}

export function createBooking(data) {
  return request('/reservas', { method: 'POST', body: data });
}

export function updateBooking(id, changes) {
  return request(`/reservas/${id}`, { method: 'PATCH', body: changes });
}
// src/api/stations.js
import { request } from './client.js';

export function getStations({ signal } = {}) {
  return request('/estaciones', { signal });
}

export function getStation(id, { signal } = {}) {
  return request(`/estaciones/${id}`, { signal });
}
// src/api/users.js
import { request } from './client.js';

export async function findUserByEmail(email) {
  // json-server returns an array when filtering; here it's normalized to an object or null
  const found = await request(`/usuarios?email=${encodeURIComponent(email)}`);
  return found[0] ?? null;
}

That encodeURIComponent isn't paranoia: an email with a + — perfectly valid and fairly common — would be interpreted as a space in the query string, and the search would find nothing. It's a bug that only shows up for certain users, which is the worst kind of bug.

  1. Why this layer knows nothing about React

Not a single React import, not a hook, not a reference to state. It's an architectural decision with five measurable consequences:

Benefit In practice
It's testable without mounting anything await getBikes() with MSW intercepting, no render, no providers
It can be reused outside React A migration script, a Cypress test, a future mobile app (10-05)
Swapping the data library doesn't affect it If TanStack Query gets replaced tomorrow, this layer isn't touched
A single point of change The real authentication from 11-05 gets added in request, and it reaches every call
A clear boundary during code review A useState inside src/api/ is an obvious mistake, not a style debate

The rule that sums it up: src/api/ speaks HTTP; src/queries/ speaks React. If a function needs to know whether a component is mounted, it's in the wrong folder.

  1. queryClient.js and its defaults

// src/queries/queryClient.js
import { QueryClient } from '@tanstack/react-query';
import { ApiError } from '../api/client.js';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      gcTime: 5 * 60_000,
      refetchOnWindowFocus: true,
      retry: (attempts, error) => {
        // A 404 doesn't get better by retrying: it's a correct answer to a badly formed question
        if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
          return false;
        }
        return attempts < 2;
      }
    },
    mutations: {
      retry: false   // retrying a POST could create two bookings
    }
  }
});

Each value, with its justification for this project:

Option Value Why
staleTime 30 s Bike status changes with real network usage, but not every second. Half a minute avoids a storm of requests when navigating between screens while keeping the data reasonably fresh
gcTime 5 min Returning to a recently visited screen is instant, and memory doesn't grow unchecked
refetchOnWindowFocus true Someone who leaves the tab open for twenty minutes and comes back should see the current status, not the one from before lunch
Query retry Function Retrying a 4xx is wasted time: the response isn't going to change. 5xx errors and network failures do get retried twice
Mutation retry false A POST /reservas retried after a timed-out request can create two bookings. The API isn't idempotent and there's no idempotency key

The last row is the most important, and the one most often ignored. If the request reached the server and the response got lost along the way, the retry creates a duplicate. With bookings, that's money. When in doubt, a mutation doesn't retry itself: the person is offered a retry button instead.

And a fine-tuned setting per resource, wherever the global value doesn't fit:

// Stations change month to month, not minute to minute
export function useStations() {
  return useQuery({
    queryKey: keys.stations.all(),
    queryFn: ({ signal }) => getStations({ signal }),
    staleTime: 10 * 60_000        // 10 minutes: overrides the global value
  });
}

  1. The key factory

// src/queries/keys.js
export const keys = {
  bikes: {
    all: () => ['bikes'],
    list: (filters) => ['bikes', filters],
    detail: (id) => ['bikes', 'detail', id]
  },
  stations: {
    all: () => ['stations'],
    detail: (id) => ['stations', id],
    incidents: (id) => ['stations', id, 'incidents']
  },
  bookings: {
    all: () => ['bookings'],
    byUser: (userId) => ['bookings', { user: userId }]
  }
};

The hierarchy is what makes invalidation precise without being tedious:

Invalidating Affects Does not affect
['bikes'] Every list and every detail page Stations and bookings
['bikes', { type: 'urbana' }] Only that filtered list The other lists
['bikes', 'detail', 'bici-001'] Only that detail page The list
['bookings'] Every user's Bikes

TanStack Query compares keys by prefix, so invalidating the parent reaches every child. That's exactly the behaviour you want after creating a booking: the list changes, that bike's detail page changes, and the bookings list changes — and two lines mark all three.

  1. The project's query hooks

// src/queries/bikes.js
import { useQuery } from '@tanstack/react-query';
import { getBikes, getBike } from '../api/bikes.js';
import { keys } from './keys.js';

export function useBikes(filters = {}) {
  return useQuery({
    queryKey: keys.bikes.list(filters),
    // signal is provided by Query: it cancels the request if the component unmounts
    queryFn: ({ signal }) => getBikes({ ...filters, signal })
  });
}

export function useBike(id) {
  return useQuery({
    queryKey: keys.bikes.detail(id),
    queryFn: ({ signal }) => getBike(id, { signal }),
    enabled: Boolean(id)   // without an id, no request is fired
  });
}
// src/queries/bookings.js
import { useQuery } from '@tanstack/react-query';
import { getBookings } from '../api/bookings.js';
import { keys } from './keys.js';

export function useBookings(userId) {
  return useQuery({
    queryKey: keys.bookings.byUser(userId),
    queryFn: ({ signal }) => getBookings({ userId, signal }),
    enabled: Boolean(userId)   // without a session, there are no bookings to fetch
  });
}

The enabled option deserves a note. Without it, entering /reservas without a session would fire GET /reservas?user=undefined, which json-server answers with an empty list and a real API would answer with a 400. With enabled: false, the query stays in pending state without requesting anything, and it kicks off on its own as soon as userId stops being null. It's the correct way to express "this depends on something I don't have yet."

  1. Connecting the catalogue: from toggles to real states

This is where the work from 11-02 pays off. The LOADING and WITH_ERROR toggles disappear and their markup stays exactly as it is.

// src/pages/CataloguePage.jsx
import { useMemo, useDeferredValue } from 'react';
import { useSearchParams } from 'react-router';
import { useSelector, useDispatch } from 'react-redux';
import { useBikes } from '../queries/bikes.js';
import { useStations } from '../queries/stations.js';
import { selectSearchTerm, selectSortOrder, searchTermChanged }
  from '../features/catalogue/catalogueSlice.js';
import Panel from '../components/base/Panel.jsx';
import TypeSelector from '../components/TypeSelector.jsx';
import BikeSearch from '../components/BikeSearch.jsx';
import BikeList from '../components/BikeList.jsx';
import FleetSummary from '../components/FleetSummary.jsx';
import PageSkeleton from '../components/PageSkeleton.jsx';
import Notice from '../components/Notice.jsx';
import Button from '../components/base/Button.jsx';
import styles from './CataloguePage.module.css';

function CataloguePage() {
  // 1) The filter lives in the URL
  const [params, setParams] = useSearchParams();
  const type = params.get('tipo') ?? 'todos';

  // 2) The search term and the sort order live in Redux
  const searchTerm = useSelector(selectSearchTerm);
  const sort = useSelector(selectSortOrder);
  const dispatch = useDispatch();

  // 3) The data lives on the server
  const query = useBikes({ type });
  const { data: stations = [] } = useStations();

  const deferredSearchTerm = useDeferredValue(searchTerm);

  const visible = useMemo(() => {
    const text = deferredSearchTerm.trim().toLowerCase();
    const list = (query.data ?? []).filter((bike) =>
      bike.model.toLowerCase().includes(text)
    );

    return [...list].sort((a, b) =>
      sort === 'price' ? a.pricePerHour - b.pricePerHour : a.model.localeCompare(b.model)
    );
  }, [query.data, deferredSearchTerm, sort]);

  function handleTypeChange(newType) {
    // replace: true keeps every filter click from filling up the history
    if (newType === 'todos') {
      setParams({}, { replace: true });
    } else {
      setParams({ tipo: newType }, { replace: true });
    }
  }

  if (query.isPending) return <PageSkeleton rows={5} />;

  if (query.isError) {
    return (
      <Notice tone="error" title="Could not load the bikes">
        <p>{query.error.friendlyMessage ?? query.error.message}</p>
        <Button onClick={() => query.refetch()}>Retry</Button>
      </Notice>
    );
  }

  return (
    <>
      <h1 className={styles.title}>Bike catalogue</h1>
      <p className={styles.intro} aria-live="polite">
        {visible.length} of {query.data.length} bikes
        {query.isFetching && <span className={styles.updating}> · updating…</span>}
      </p>

      <FleetSummary bikes={query.data} />

      <Panel title="Filters" level={2} className={styles.filters}>
        <TypeSelector selectedType={type} onTypeChange={handleTypeChange} />
        <BikeSearch
          searchTerm={searchTerm}
          onSearchTermChange={(value) => dispatch(searchTermChanged(value))}
        />
      </Panel>

      {visible.length === 0 ? (
        <div className={styles.empty}>
          <h2>No bike matches the search</h2>
          <p>Try another type or clear the search text.</p>
        </div>
      ) : (
        <BikeList bikes={visible} stations={stations} />
      )}
    </>
  );
}

export default CataloguePage;

The four decisions that define this screen:

  1. The type filter goes to the server; the text search doesn't. The type is part of the query key, so each type is cached separately and going back to "Electric" is instant. The text, on the other hand, changes with every keystroke: sending it to the server would mean one request per keypress. It's filtered client-side over data that's already loaded.
  2. isPending versus isFetching. isPending means "there's no data yet" and renders the skeleton. isFetching means "there's data, and it's also being refreshed," and it's flagged with discreet text: replacing the list with a skeleton on every revalidation would be a constant, unjustified flicker.
  3. replace: true when filtering. Without it, choosing four filters in a row forces you to press back four times to leave the screen. The filter must be linkable, but not every intermediate step has to be a history entry.
  4. aria-live="polite" on the count. When filtering, someone who can't see the screen needs to know the number of results has changed. It's one line, and it solves a real problem.

  1. The complete mutation: creating a booking

The product's central operation, with its six steps.

// src/queries/bookings.js — continued
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createBooking } from '../api/bookings.js';
import { keys } from './keys.js';

export function useCreateBooking() {
  const client = useQueryClient();

  return useMutation({
    mutationFn: createBooking,

    onSuccess: (createdBooking) => {
      // The user's booking list has changed
      client.invalidateQueries({
        queryKey: keys.bookings.byUser(createdBooking.user)
      });
      // The bike is now committed: the entire catalogue may have changed
      client.invalidateQueries({ queryKey: keys.bikes.all() });
    }
  });
}
// src/pages/NewBookingPage.jsx
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { useSelector } from 'react-redux';
import { useBikes } from '../queries/bikes.js';
import { useCreateBooking } from '../queries/bookings.js';
import { selectUser } from '../features/session/sessionSlice.js';
import { useNotices } from '../contexts/notices.js';
import { validateBooking } from '../utils/validateBooking.js';
import BookingForm from '../components/BookingForm.jsx';
import PageSkeleton from '../components/PageSkeleton.jsx';
import Notice from '../components/Notice.jsx';

function NewBookingPage() {
  const navigate = useNavigate();
  const [params] = useSearchParams();
  const user = useSelector(selectUser);
  const { addNotice } = useNotices();

  const bikesQuery = useBikes();
  const mutation = useCreateBooking();

  const [errors, setErrors] = useState({});

  const initialValue = {
    // Preselected from the detail page: /reservas/nueva?bicicleta=bici-001
    bicicletaId: params.get('bicicleta') ?? '',
    startDate: '',
    hours: 1,
    terms: false
  };

  function handleSubmit(data) {
    // 1) Validate against the REAL data, not a copy
    const foundErrors = validateBooking(data, bikesQuery.data ?? []);
    setErrors(foundErrors);

    if (Object.keys(foundErrors).length > 0) return;

    // 2) Submit
    mutation.mutate(
      {
        id: `res-${Date.now()}`,          // json-server accepts the id; a real API would generate it
        bicicletaId: data.bicicletaId,
        user: user.id,
        startDate: data.startDate,
        hours: Number(data.hours),
        status: 'activa'
      },
      {
        // 3) Success: notice and redirect
        onSuccess: () => {
          addNotice({ tone: 'success', text: 'Booking created successfully.' });
          navigate('/reservas', { replace: true });
        },
        // 4) Error: inline notice, without leaving the screen
        onError: (error) => {
          addNotice({
            tone: 'error',
            text: `Could not create the booking. ${error.message}`
          });
        }
      }
    );
  }

  if (bikesQuery.isPending) return <PageSkeleton rows={4} />;

  if (bikesQuery.isError) {
    return (
      <Notice tone="error" title="Could not load the catalogue">
        Without the bike list, a booking can't be created. Please try again.
      </Notice>
    );
  }

  return (
    <>
      <h1>New booking</h1>
      <BookingForm
        bikes={bikesQuery.data}
        initialValue={initialValue}
        errors={errors}
        submitting={mutation.isPending}
        onSubmit={handleSubmit}
      />
    </>
  );
}

export default NewBookingPage;

The six steps, and the reason for each:

Step Where Why this way
1. Validate In the page, before mutate validateBooking needs the real bike list to check that the chosen one is disponible. Validating against data from five minutes ago would allow booking a bike that's already rented
2. Submit mutation.mutate isPending disables the button: protection against double submission isn't a useState of its own
3. Invalidate The hook's onSuccess It goes in the hook, not in the page: it's a consequence of the domain, not of this screen. If another screen creates bookings, the invalidation happens there too
4. Notify The call's onSuccess It goes in the page: the notice and the redirect are decisions specific to this screen
5. Redirect navigate('/reservas', { replace: true }) replace keeps the back button from returning to the already-submitted form, with the risk of a second submission (06-04)
6. Handle the error The call's onError A mutation's failure must not knock you out of the screen: the entered data is still there and it can be retried

The distinction between the hook's onSuccess and the call's is subtle and very useful: the hook's expresses what's always true (the affected data becomes stale), the call's expresses what this screen wants (notify and navigate). Both run, the hook's first.

  1. Optimistic updates: confirming and cancelling

Cancelling a booking is an operation where waiting half a second for the server to respond is noticeable. Optimistic updates render the result before you actually have it, and roll it back if it fails.

// src/queries/bookings.js — continued
export function useCancelBooking(userId) {
  const client = useQueryClient();
  const key = keys.bookings.byUser(userId);

  return useMutation({
    mutationFn: (bookingId) => updateBooking(bookingId, { status: 'cancelada' }),

    onMutate: async (bookingId) => {
      // 1) Stop in-flight queries: if one lands later, it would overwrite the change
      await client.cancelQueries({ queryKey: key });

      // 2) Save the current snapshot so it can be rolled back
      const previous = client.getQueryData(key);

      // 3) Render the result now
      client.setQueryData(key, (bookings = []) =>
        bookings.map((booking) =>
          booking.id === bookingId ? { ...booking, status: 'cancelada' } : booking
        )
      );

      // What's returned reaches onError and onSettled as "context"
      return { previous };
    },

    onError: (error, bookingId, context) => {
      // 4) Roll back to the exact previous state
      if (context?.previous) {
        client.setQueryData(key, context.previous);
      }
    },

    onSettled: () => {
      // 5) Whatever happens, sync with the server
      client.invalidateQueries({ queryKey: key });
      client.invalidateQueries({ queryKey: keys.bikes.all() });
    }
  });
}
sequenceDiagram
    participant U as User
    participant C as Component
    participant Q as Query Cache
    participant S as Server

    U->>C: Clicks "Yes, cancel"
    C->>Q: mutate(bookingId)
    Q->>Q: onMutate: cancelQueries + snapshot + setQueryData
    Q-->>C: The row already shows "Cancelled"
    Q->>S: PATCH /reservas/res-01
    alt Successful response
        S-->>Q: 200 OK
        Q->>Q: onSettled: invalidateQueries
        Q->>S: GET /reservas (confirmation)
    else Error
        S-->>Q: 500
        Q->>Q: onError: setQueryData(snapshot)
        Q-->>C: The row goes back to "Active"
        C-->>U: Notice "Could not cancel"
    end

The three steps you can't skip, with the exact consequence of omitting each one:

Step If skipped
cancelQueries A query fired before the mutation lands afterward and restores the old state. The perfect intermittent bug: it happens once every ten times
Saving previous There's nothing to roll back to. The UI keeps lying until the next reload
onSettled with invalidateQueries The cache keeps the guessed value, not the real one. If the server stored something different — a timestamp, a derived status — you never find out

And the underlying question: when is it worth it?

Operation Optimistic? Reason
Cancelling a booking Yes A single field changes, predictable result, rare and reversible failure
Confirming a booking Yes Same
Changing a bike's status (workshop) Yes Same, and the operator does many in a row
Creating a booking No The server assigns the identifier; guessing it forces reconciliation afterward. And if it fails, something the person already saw as created has to be pulled from the list
Any operation involving payment No Something involving money is never shown as done when it isn't confirmed

  1. Redux: the session

// src/features/session/sessionSlice.js
import { createSlice } from '@reduxjs/toolkit';

const STORAGE_KEY = 'ciclourbano:sesion';

function readStoredSession() {
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    return stored ? JSON.parse(stored) : null;
  } catch {
    // Corrupted JSON or blocked storage: start without a session
    return null;
  }
}

const initialState = {
  user: readStoredSession(),
  loading: false,
  error: null
};

const sessionSlice = createSlice({
  name: 'session',
  initialState,
  reducers: {
    signInStarted(state) {
      state.loading = true;
      state.error = null;
    },
    signedIn(state, action) {
      state.user = action.payload;
      state.loading = false;
      state.error = null;
    },
    signInFailed(state, action) {
      state.user = null;
      state.loading = false;
      state.error = action.payload;
    },
    signedOut(state) {
      state.user = null;
      state.error = null;
    }
  }
});

export const { signInStarted, signedIn, signInFailed, signedOut } =
  sessionSlice.actions;

// Selectors: the slice is the only one that knows the shape of the state
export const selectUser = (state) => state.session.user;
export const selectIsSignedIn = (state) => state.session.user !== null;
export const selectIsOperator = (state) => state.session.user?.role === 'operario';
export const selectSessionLoading = (state) => state.session.loading;
export const selectSessionError = (state) => state.session.error;

export default sessionSlice.reducer;

Persistence is handled with a middleware, instead of repeating localStorage.setItem in every reducer:

// src/store/sessionPersistence.js
const STORAGE_KEY = 'ciclourbano:sesion';

export const sessionPersistence = (store) => (next) => (action) => {
  const result = next(action);

  // Only reacts to session actions: it doesn't write on every keystroke in the search box
  if (action.type.startsWith('session/')) {
    const user = store.getState().session.user;
    try {
      if (user) {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
      } else {
        localStorage.removeItem(STORAGE_KEY);
      }
    } catch {
      // Private mode or quota full: the app keeps working without persistence
    }
  }

  return result;
};
// src/store/store.js
import { configureStore } from '@reduxjs/toolkit';
import sessionReducer from '../features/session/sessionSlice.js';
import catalogueReducer from '../features/catalogue/catalogueSlice.js';
import bookingsReducer from '../features/bookings/bookingsSlice.js';
import { sessionPersistence } from './sessionPersistence.js';

export const store = configureStore({
  reducer: {
    session: sessionReducer,
    catalogue: catalogueReducer,
    bookings: bookingsReducer
  },
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(sessionPersistence)
});

Why a middleware and not a useEffect in a component, nor useLocalStorage:

Approach Problem
A useEffect watching the user Only works if the component is mounted. A sign-out triggered from somewhere without that component doesn't persist
useLocalStorage in the sign-in component Two sources of truth: the hook and Redux. They fall out of sync as soon as someone dispatches signedOut from somewhere else
Middleware Sees every action, no matter what's mounted. A single point, impossible to bypass

And the sign-in page, which ties it all together:

// src/pages/SignInPage.jsx (logic excerpt)
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useLocation } from 'react-router';
import { findUserByEmail } from '../api/users.js';
import { signInStarted, signedIn, signInFailed, selectSessionLoading, selectSessionError }
  from '../features/session/sessionSlice.js';

function SignInPage() {
  const [email, setEmail] = useState('');
  const dispatch = useDispatch();
  const navigate = useNavigate();
  const location = useLocation();
  const loading = useSelector(selectSessionLoading);
  const error = useSelector(selectSessionError);

  // Where to go back to: ProtectedRoute left it there when it kicked the person out
  const returnTo = location.state?.from?.pathname ?? '/';

  async function handleSubmit(event) {
    event.preventDefault();
    dispatch(signInStarted());

    try {
      const user = await findUserByEmail(email.trim());

      if (!user) {
        dispatch(signInFailed('There is no account with that email.'));
        return;
      }

      dispatch(signedIn(user));
      navigate(returnTo, { replace: true });
    } catch (failure) {
      dispatch(signInFailed(failure.message));
    }
  }
  // …the markup is the one from 11-02, wired up with loading and error
}

And the warning that needs to be said out loud: this is not authentication. It's a lookup by email with no password, no token, and no verification of anything — acceptable in a learning project with json-server and absolutely insufficient for production. 11-05 spells out what would really be needed.

  1. Redux: the catalogue, and what doesn't belong in Redux

// src/features/catalogue/catalogueSlice.js
import { createSlice } from '@reduxjs/toolkit';

const catalogueSlice = createSlice({
  name: 'catalogue',
  initialState: { searchTerm: '', sort: 'model' },
  reducers: {
    searchTermChanged(state, action) {
      state.searchTerm = action.payload;
    },
    sortChanged(state, action) {
      state.sort = action.payload;
    },
    filtersReset(state) {
      state.searchTerm = '';
      state.sort = 'model';
    }
  }
});

export const { searchTermChanged, sortChanged, filtersReset } = catalogueSlice.actions;

export const selectSearchTerm = (state) => state.catalogue.searchTerm;
export const selectSortOrder = (state) => state.catalogue.sort;

export default catalogueSlice.reducer;

Notice what's not there: type doesn't appear anywhere, because it lives in the URL. Having it in both places would guarantee that one day they fall out of sync.

The list of what was deliberately left out of Redux, with its reason:

Data Why it's not in Redux
Bikes, stations, bookings Server state: it belongs to TanStack Query (A3)
Type filter It belongs to the URL: it must be shareable (A6)
Theme Context: two values and no demanding consumer
Notices Context: emitted by anyone and rendered by the frame
Modal open, form draft Local: no one else needs them
Mutations' isPending Query already provides it; duplicating it guarantees desync

The rule that sums up all six rows: only what two distant components need to share, and doesn't fit better in another tool, goes up to the global store. Redux isn't the place where everything goes; it's the place where what belongs there goes.

  1. Context: theme and notices

ThemeProvider was already written in 11-02. Notices follow the split-context pattern from 07-02, because it's the one that avoids most unnecessary re-renders:

// src/contexts/notices.jsx
import { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react';

const NoticesStateContext = createContext(null);
const NoticesActionsContext = createContext(null);

export function NoticesProvider({ children }) {
  const [notices, setNotices] = useState([]);
  const nextId = useRef(1);

  const addNotice = useCallback(({ tone = 'info', text, duration = 5000 }) => {
    const id = nextId.current++;
    setNotices((current) => [...current, { id, tone, text }]);

    if (duration > 0) {
      setTimeout(() => {
        setNotices((current) => current.filter((notice) => notice.id !== id));
      }, duration);
    }

    return id;
  }, []);

  const removeNotice = useCallback((id) => {
    setNotices((current) => current.filter((notice) => notice.id !== id));
  }, []);

  // Actions NEVER change identity: emitters never re-render because of them
  const actions = useMemo(() => ({ addNotice, removeNotice }), [addNotice, removeNotice]);

  return (
    <NoticesActionsContext.Provider value={actions}>
      <NoticesStateContext.Provider value={notices}>
        {children}
      </NoticesStateContext.Provider>
    </NoticesActionsContext.Provider>
  );
}

export function useNotices() {
  const context = useContext(NoticesActionsContext);
  if (!context) throw new Error('useNotices must be used within NoticesProvider.');
  return context;
}

export function useNoticeList() {
  const context = useContext(NoticesStateContext);
  if (context === null) throw new Error('useNoticeList must be used within NoticesProvider.');
  return context;
}

The reason for splitting it in two is concrete and measurable. NewBookingPage only needs to emit notices; NoticeList only needs to read them. With a single context, every notice that appears and disappears would re-render the entire form. With two, the form consumes a value that never changes identity — thanks to useCallback with empty dependencies — and never re-renders because of notices.

// src/components/NoticeList.jsx
import { memo } from 'react';
import { useNoticeList, useNotices } from '../contexts/notices.jsx';
import styles from './NoticeList.module.css';

function NoticeList() {
  const notices = useNoticeList();
  const { removeNotice } = useNotices();

  if (notices.length === 0) return null;

  return (
    <div className={styles.list}>
      {notices.map((notice) => (
        <div
          key={notice.id}
          className={`${styles.notice} ${styles[notice.tone]}`}
          data-testid="aviso"
          /* Errors interrupt; the rest wait their turn */
          role={notice.tone === 'error' ? 'alert' : 'status'}
        >
          <p>{notice.text}</p>
          <button type="button" onClick={() => removeNotice(notice.id)} aria-label="Close notice">
            ×
          </button>
        </div>
      ))}
    </div>
  );
}

export default memo(NoticeList);

The choice of role isn't cosmetic: alert interrupts a screen reader's current reading and status waits for it to finish. An error deserves the interruption; a "Booking created" doesn't.

  1. The filter in the URL with useSearchParams

It's already used in the catalogue; it's worth understanding the full chain, because it's what makes a shared URL work:

flowchart LR
    A["URL: /?tipo=electrica"] --> B["useSearchParams<br/>type = 'electrica'"]
    B --> C["keys.bikes.list({type:'electrica'})"]
    C --> D["Is it cached and fresh?"]
    D -- "Yes" --> E["Instant data"]
    D -- "No" --> F["GET /bicicletas?tipo=electrica"]
    F --> E
    E --> G["BikeList"]
    H["Click 'Urban'"] --> I["setParams({tipo:'urbana'}, {replace:true})"]
    I --> A

What's gained, and that no other state location gives you:

  • Shareable link: pasting /?tipo=electrica into a chat takes whoever opens it to exactly that view.
  • Faithful reload: F5 doesn't lose the filter.
  • Coherent back button: after entering a detail page and going back, the filter is still applied.
  • Free cache key: every filter has its own entry in Query's cache, so switching between types you've already seen is instant.

And the detail that needs care: the URL is user input, and it can carry garbage. ?tipo=cohete must not break anything:

const VALID_TYPES = ['todos', 'urbana', 'electrica', 'carga'];

const rawType = params.get('tipo') ?? 'todos';
const type = VALID_TYPES.includes(rawType) ? rawType : 'todos';

Without that sanitization, a made-up value would produce a new query key, a useless request, and an empty list with no explanation.

  1. Protected routes wired to the real session

// src/components/ProtectedRoute.jsx
import { Navigate, Outlet, useLocation } from 'react-router';
import { useSelector } from 'react-redux';
import { selectUser, selectSessionLoading }
  from '../features/session/sessionSlice.js';
import PageSkeleton from './PageSkeleton.jsx';

function ProtectedRoute() {
  const user = useSelector(selectUser);
  const loading = useSelector(selectSessionLoading);
  const location = useLocation();

  // While it's not known whether there's a session, NOTHING is decided
  if (loading) return <PageSkeleton rows={3} />;

  if (!user) {
    // state.from: SignInPage uses it to come back here after signing in
    return <Navigate to="/acceso" replace state={{ from: location }} />;
  }

  return <Outlet />;
}

export default ProtectedRoute;
// src/components/RequireRole.jsx
import { Navigate, Outlet } from 'react-router';
import { useSelector } from 'react-redux';
import { selectUser } from '../features/session/sessionSlice.js';

function RequireRole({ role }) {
  const user = useSelector(selectUser);

  // Without permission, there's NO redirect to /acceso: the person is already signed in.
  // Sending them to sign-in would suggest the problem is with the session, and it isn't.
  if (user?.role !== role) {
    return <Navigate to="/sin-permisos" replace />;
  }

  return <Outlet />;
}

export default RequireRole;

The loading state looks unnecessary with the session read synchronously from localStorage, and it is, today; it's kept because the day the session gets validated against the server — the normal case in production — there will be an instant when it isn't known whether there's a session, and without this guard, that instant would kick out to /acceso someone who really was signed in. It's a classic, very annoying flicker.

And the warning that needs repeating every time this code comes up: this is UI access control, not security. It stops someone from accidentally navigating to a screen that isn't theirs, and nothing more. Anyone can open the dev tools, modify Redux state, and see /taller; and if the "Send to maintenance" button fires a PATCH that the server accepts without checking anything, the damage is real. Authorization is checked on the server, on every request, always. What happens on the client is a convenience.

  1. End-to-end error handling

With the network connected, real errors show up. The question that needs answering for each one is who shows it, and this table is the project's answer:

Situation Who shows it What the person sees Recovers with
Network failure loading the catalogue The screen itself (isError) Notice with a message and a "Retry" button refetch()
Server 500 while loading Same Same refetch(), after two automatic retries
404 for a nonexistent bike The detail screen "This bike does not exist" + link to the catalogue Navigating
Nonexistent URL (/inventada) Route * NotFoundPage Navigating
Insufficient role RequireRole ForbiddenPage Switching sessions
Expired session (401) request's middleware → sign-out Redirect to /acceso with a notice Signing in
Error while mutating (creating a booking) Inline notice, without leaving "Could not create the booking" Submitting again
Exception rendering a route errorElement RouteErrorPage, with header and footer intact Navigating or reloading
Exception outside the router main.jsx's ErrorBoundary General failure screen Reloading
lazy chunk that fails to load The route's errorElement Same, with an invitation to reload Reloading
flowchart TD
    A["An error occurred"] --> B{"Data or<br/>rendering?"}
    B -- "Rendering" --> C{"Inside a route?"}
    C -- "Yes" --> D["errorElement<br/>RouteErrorPage"]
    C -- "No" --> E["ErrorBoundary<br/>general screen"]
    B -- "Data" --> F{"Query or mutation?"}
    F -- "Query" --> G{"Which code?"}
    G -- "404" --> H["Screen's own message"]
    G -- "401" --> I["Sign out and redirect to /acceso"]
    G -- "other" --> J["Notice with Retry (refetch)"]
    F -- "Mutation" --> K["Inline notice<br/>without losing entered data"]

The principle that orders the whole picture: the more localized the error, the more localized its response must be. A catalogue query failing doesn't justify bringing down the entire application; a component's rendering failing does justify replacing that branch. And in no case is a blank screen shown.

The expired session is resolved in the data layer, so that no screen has to remember it:

// src/api/client.js — added to error handling
import { store } from '../store/store.js';
import { signedOut } from '../features/session/sessionSlice.js';

if (response.status === 401) {
  store.dispatch(signedOut());
  // The router will react: ProtectedRoute will see user === null and redirect
}

It's the only concession src/api/ makes to something that isn't HTTP, and it's accepted because the alternative — repeating the 401 check in every hook — is worse. Even so, it imports the store, not React: the layer remains usable outside a component.

  1. The final main.jsx

// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { RouterProvider } from 'react-router';

import { store } from './store/store.js';
import { queryClient } from './queries/queryClient.js';
import { router } from './routes.jsx';
import ErrorBoundary from './components/ErrorBoundary.jsx';
import { Providers } from './contexts/Providers.jsx';
import { reportError } from './utils/monitoring.js';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <ErrorBoundary title="CicloUrbano is not available right now" onReport={reportError}>
      <QueryClientProvider client={queryClient}>
        <Provider store={store}>
          <Providers>
            <RouterProvider router={router} />
          </Providers>
        </Provider>
        <ReactQueryDevtools initialIsOpen={false} />
      </QueryClientProvider>
    </ErrorBoundary>
  </StrictMode>
);
// src/contexts/Providers.jsx
import { ThemeProvider } from './ThemeProvider.jsx';
import { NoticesProvider } from './notices.jsx';

export function Providers({ children }) {
  return (
    <ThemeProvider>
      <NoticesProvider>{children}</NoticesProvider>
    </ThemeProvider>
  );
}

The order isn't arbitrary, and every level has its reason:

Level Why it's there
StrictMode Detects non-idempotent effects in development (04-03). The outermost
ErrorBoundary Must be able to catch a failure from any provider, so it goes outside all of them
QueryClientProvider Outside Redux: a middleware might want to invalidate queries, and not the other way around
Provider (Redux) Outside the router: ProtectedRoute and RequireRole read the session
Providers Theme and notices are needed by the whole route tree
RouterProvider The innermost. Everything above must be available inside the routes

The general rule, and it holds for any project: a provider goes outside everything that consumes it. RouterProvider is always last because the routes consume everything else.

  1. The complete booking flow

Everything in this lesson, in a single walkthrough:

sequenceDiagram
    participant U as Ana (user)
    participant P as NewBookingPage
    participant V as validateBooking
    participant M as useCreateBooking
    participant A as src/api/bookings.js
    participant S as json-server
    participant Q as Query Cache
    participant C as CataloguePage

    U->>P: Fills out the form and submits
    P->>V: validateBooking(data, bikes from the cache)
    alt There are errors
        V-->>P: { hours: 'The maximum booking is 24 hours.' }
        P-->>U: Messages next to each field (role="alert")
    else Valid
        V-->>P: {}
        P->>M: mutate(booking)
        M-->>P: isPending: the button is disabled
        M->>A: createBooking(data)
        A->>S: POST /reservas
        S-->>A: 201 + booking created
        A-->>M: booking object
        M->>Q: invalidateQueries(user's bookings)
        M->>Q: invalidateQueries(bikes)
        M-->>P: onSuccess
        P->>U: Notice "Booking created successfully"
        P->>U: navigate('/reservas', replace)
        Q->>S: GET /reservas?user=usr-01 (automatic refresh)
        S-->>Q: updated list
        Q-->>C: Back on the catalogue, data already fresh
    end

Notice what does not appear in the diagram and yet happens: no one writes "reload the bookings list." Invalidation marks the data as stale and Query decides when to fetch it: instantly if there's a mounted component watching that key, or the next time around if there isn't. That's the work 07-06 argued wasn't worth reimplementing, and here you can see why.

  1. Manual walkthrough check

Before writing the first automated test (11-04), the complete walkthrough by hand, with the network tab open:

# Action What should happen
1 Start with npm run dev:todo and open / Skeleton, then the list. A single GET /bicicletas request
2 Go to /estaciones and back to / The second time there's no request: staleTime of 30 s
3 Wait 40 s and go back Now it does refresh, and the list doesn't flicker (isFetching, not isPending)
4 Click "Electric" URL with ?tipo=electrica, new request, two results
5 Reload the page The filter is still applied
6 Copy the URL into another tab Same filtered view
7 Type "carga" into the search box No request: it's filtered client-side
8 Go to /reservas without a session Redirect to /acceso
9 Sign in with [email protected] Returns to /reservas, not to the home page
10 Reload The session persists
11 Create a valid booking Success notice, redirect, the booking appears in the list
12 Go back to the catalogue The bike's status has been updated
13 Cancel a booking The row changes instantly; the confirmation arrives afterward
14 Stop json-server and cancel another one The row changes and reverts, with an error notice
15 With the API stopped, reload / Error notice with "Retry"
16 Start the API and click "Retry" The list appears
17 Open /bicicletas/no-existe The bike-not-found message
18 As Ana, open /taller ForbiddenPage
19 Sign in with [email protected] and open /taller It's visible, with maintenance first
20 Sign out Back to the catalogue, /reservas protected again

Steps 13 and 14 are the ones to take slowly: optimistic updates are the part that fails silently most often, and seeing the rollback with your own eyes is the only way to know it's wired correctly.

Common Mistakes and Tips

  • Storing server data in a useState with a useEffect. This is the pattern TanStack Query exists to eliminate: no caching, no deduplication, race conditions, and half-baked error state. If a useEffect that does fetch shows up, something has gone wrong.
  • Duplicating isPending in its own useState. Produces the button that stays loading forever because the error branch forgot to set it back to false. The mutation's state already exists: use it.
  • Forgetting cancelQueries in onMutate. It's the perfect intermittent bug: an in-flight query lands after the optimistic update and restores the old value. It fails once every ten times and is a nightmare to diagnose.
  • Not returning the context from onMutate. Without the previous snapshot there's no possible rollback, and the UI keeps showing a change the server rejected.
  • Retrying mutations automatically. A POST retried after a timed-out request can create two bookings. retry: false on mutations, and a retry button for the person instead.
  • Retrying a 404. Three requests and three seconds to arrive at the same response. The retry function must distinguish 4xx from 5xx.
  • Putting the same data in two places. The type filter in the URL and in Redux is the guaranteed recipe for desync. One piece of data, one owner.
  • Trusting ProtectedRoute as a security measure. It's a UI convenience. Authorization is checked on the server on every request, without exception.
  • Invalidating ['bikes'] from the page instead of the hook. If the invalidation is a consequence of the domain, it belongs in the mutation hook: that way it happens from any screen that uses that hook, today and a year from now.
  • Not sanitizing URL parameters. ?tipo=cohete produces a new cache key, a useless request, and an empty list with no explanation.
  • Tip: keep the TanStack Query DevTools open while developing. Seeing the keys, their state (fresh, stale, inactive), and their refreshes turns what would otherwise be hours of guesswork into something obvious.
  • Tip: always test with the API stopped. It's the fastest way to confirm the error states genuinely exist and that you can get out of them.

Exercises

Exercise 1. Implement the workshop feature (H7): the useChangeBikeStatus hook with optimistic updates and its wiring into WorkshopPage. It must update both the bike list and the individual detail page if it's cached, roll back on error, and report the result. State which keys you invalidate and why, and what happens if the operator clicks two buttons in quick succession.

Exercise 2. A colleague reports this bug: "if I go into the catalogue, filter by electric, open a detail page, and go back, sometimes I see the unfiltered list for an instant." Diagnose the two possible causes, explain how to tell which one it is, and fix whichever one applies to the project as written in this lesson.

Exercise 3. The real production API is going to return 401 when the token expires, and the team wants the person not to lose what they were doing: instead of kicking them out instantly, they should see a notice with a button to sign in again that, once they do, takes them back to the screen they were on. Design the solution, stating which layer is responsible for what, write the code for the new parts, and explain what's wrong with the current solution from section 16.

Solutions

Solution 1.

// src/queries/bikes.js — continued
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateBikeStatus } from '../api/bikes.js';

export function useChangeBikeStatus() {
  const client = useQueryClient();

  return useMutation({
    mutationFn: ({ id, status }) => updateBikeStatus(id, status),

    onMutate: async ({ id, status }) => {
      // 1) Stop ALL bike queries, lists and details
      await client.cancelQueries({ queryKey: keys.bikes.all() });

      // 2) Snapshot every affected entry: there are several lists (one per type)
      const previousLists = client.getQueriesData({ queryKey: keys.bikes.all() });

      // 3) Update every cached list
      client.setQueriesData({ queryKey: keys.bikes.all() }, (data) => {
        if (!Array.isArray(data)) return data;   // the detail entry isn't an array
        return data.map((bike) => (bike.id === id ? { ...bike, status } : bike));
      });

      // 4) And the individual detail page, if it's cached
      client.setQueryData(keys.bikes.detail(id), (bike) =>
        bike ? { ...bike, status } : bike
      );

      return { previousLists, id };
    },

    onError: (error, variables, context) => {
      // Full rollback: every entry is restored with its original key
      context?.previousLists?.forEach(([key, data]) => {
        client.setQueryData(key, data);
      });
    },

    onSettled: (data, error, variables) => {
      client.invalidateQueries({ queryKey: keys.bikes.all() });
      client.invalidateQueries({ queryKey: keys.bikes.detail(variables.id) });
    }
  });
}
// src/pages/WorkshopPage.jsx (excerpt)
function WorkshopPage() {
  const query = useBikes();
  const mutation = useChangeBikeStatus();
  const { addNotice } = useNotices();

  function handleChange(bike, newStatus) {
    mutation.mutate(
      { id: bike.id, status: newStatus },
      {
        onSuccess: () =>
          addNotice({
            tone: 'success',
            text: `${bike.model} is now ${newStatus === 'mantenimiento' ? 'in maintenance' : 'available'}.`
          }),
        onError: (error) =>
          addNotice({ tone: 'error', text: `Could not update it. ${error.message}` })
      }
    );
  }

  if (query.isPending) return <PageSkeleton rows={4} />;
  // …the rest of the markup from 11-02, with onClick={() => handleChange(bike, 'mantenimiento')}
}

Which keys are invalidated, and why:

Key Reason
['bikes'] Reaches every filtered list by prefix: {type:'todos'}, {type:'urbana'}… Any of them could contain that bike
['bikes','detail',id] The individual detail page is a separate entry and doesn't hang off a list
['bookings'] Not invalidated: changing a bike's status doesn't alter any existing booking. Invalidating it would be a useless request

Using the plural setQueriesData, with the Array.isArray check: the individual detail entry shares the ['bikes'] prefix and isn't an array, so without that guard the map would blow up. It's the price of hierarchical keys, and it has to be kept in mind.

If the operator clicks two buttons in quick succession: two mutations fire in parallel, and there's a real risk there. The second one runs its onMutate after the first has already modified the cache, so its previousLists snapshot includes the first one's change. If the second one fails and the first one succeeded, the second one's rollback restores a correct state — the one that included change one — so far so good. The real problem shows up if the first one fails: its rollback restores a snapshot from before either change, visually erasing the second one's change, which did succeed. onSettled with invalidateQueries fixes the discrepancy as soon as the server's response arrives, but for an instant the UI lies.

Three ways to fix it, from least to most involved:

  1. Disable that bike's button while its mutation is in flight (mutation.isPending && mutation.variables?.id === bike.id). Simple, sufficient, and honest with the user.
  2. Use useMutationState to keep track of pending mutations and apply all of them when recomputing the cache.
  3. Serialize with scope, the TanStack Query v5 option that runs mutations in the same scope one after another:
return useMutation({
  scope: { id: 'bike-status' },   // queued, never overlapping
  mutationFn: ({ id, status }) => updateBikeStatus(id, status),
  // …
});

For the workshop, 1 and 3 together are the answer provided.

Solution 2.

The two possible causes, and they're very different:

Cause A — the filter isn't in the URL, or isn't read on mount. If CataloguePage stored the type in a useState initialized to 'todos' and only synced it with the URL in a useEffect, going back would render first with 'todos' — the full list — and then with the correct value. The flicker would happen every time, not "sometimes."

Cause B — the unfiltered list's cache is shown while the filtered one arrives. If, on going back, the key ['bikes', {type:'electrica'}] is outside the cache — more than gcTime has passed, or it's the first time — and the component holds onto previous data, the old list is visible during the refresh.

How to tell them apart:

Observation Points to
It happens always, even without a network A: it's a state-synchronization problem
It happens only sometimes, and more if you take a while to go back B: it's a caching problem
The address bar's URL already carries ?tipo=electrica at the moment of the flicker A: the URL is fine, the screen ignores it
The Query DevTools show the old key as active B
With the network throttled, the flicker lasts longer B

In the project as written, the cause is B, because section 8 reads the type directly from useSearchParams during render — there's no intermediate useState nor sync effect — so A is ruled out by construction.

The fix has two parts. First, don't carry over data from another key: TanStack Query v5 doesn't do this by default, but it does happen if someone added placeholderData: keepPreviousData without understanding the effect. If it's there, either remove it or pair it with a visual cue:

const query = useBikes({ type });

// If you want to keep the previous list during the filter change,
// you have to SAY SO in the UI, not let it look like the final result
const showingPrevious = query.isPlaceholderData;

<ul className={cx(styles.list, showingPrevious && styles.dimmed)} aria-busy={showingPrevious}>

And second, the underlying fix, which also improves the experience: seed the filtered list's cache from the full one, since the type filter is a subset of data that's almost always already in memory:

export function useBikes(filters = {}) {
  const client = useQueryClient();

  return useQuery({
    queryKey: keys.bikes.list(filters),
    queryFn: ({ signal }) => getBikes({ ...filters, signal }),
    placeholderData: () => {
      // If the full list is cached, it's filtered client-side as a provisional value
      const all = client.getQueryData(keys.bikes.list({}));
      if (!all || !filters.type || filters.type === 'todos') return undefined;
      return all.filter((bike) => bike.type === filters.type);
    }
  });
}

Now, going back with ?tipo=electrica, both electric bikes show up instantly — filtered from the full list that was already in memory — and the real request confirms them afterward. No flicker, no wrong list.

Solution 3.

What's wrong with section 16's solution. It's abrupt and loses work. A dispatch(signedOut()) from request kicks the person out to /acceso the instant any request returns a 401 — including a background revalidation the person never asked for — and it takes down a half-written form with it. What's more, if several requests fail at once, several sign-outs get dispatched, and the data layer makes a navigation decision that isn't its to make.

Division of responsibilities:

Layer Responsibility
src/api/client.js Detect the 401 and throw an ApiError with status: 401. Nothing more: it doesn't dispatch or navigate
queryClient.js A global error handler that, on a 401, marks the session as expired (not signed out)
sessionSlice New expired field, distinct from user === null
SessionExpiredNotice component Shows the dialog with the button to sign in again
SignInPage On signing in, clears expired and returns to the saved screen

The new code:

// src/features/session/sessionSlice.js — additions
const initialState = {
  user: readStoredSession(),
  loading: false,
  error: null,
  expired: false          // there's a user in memory, but the server no longer accepts it
};

// inside reducers:
sessionExpired(state) {
  state.expired = true;   // the user is NOT cleared: it's needed to go back
},
sessionRenewed(state, action) {
  state.user = action.payload;
  state.expired = false;
},

export const selectSessionExpired = (state) => state.session.expired;
// src/queries/queryClient.js — global handler
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
import { store } from '../store/store.js';
import { sessionExpired } from '../features/session/sessionSlice.js';
import { ApiError } from '../api/client.js';

function handleGlobalError(error) {
  if (error instanceof ApiError && error.status === 401) {
    // Idempotent: even if five requests fail, the state ends up the same
    store.dispatch(sessionExpired());
  }
}

export const queryClient = new QueryClient({
  queryCache: new QueryCache({ onError: handleGlobalError }),
  mutationCache: new MutationCache({ onError: handleGlobalError }),
  defaultOptions: { /* …the ones from section 5… */ }
});
// src/components/SessionExpiredNotice.jsx
import { useSelector, useDispatch } from 'react-redux';
import { useLocation, useNavigate } from 'react-router';
import { selectSessionExpired, signedOut } from '../features/session/sessionSlice.js';
import Modal from './base/Modal.jsx';
import Button from './base/Button.jsx';

function SessionExpiredNotice() {
  const expired = useSelector(selectSessionExpired);
  const location = useLocation();
  const navigate = useNavigate();
  const dispatch = useDispatch();

  if (!expired) return null;

  return (
    <Modal open title="Your session has expired" onClose={() => {}}>
      <p>
        For security, the session was closed after a period of inactivity.
        Sign in again to continue where you left off.
      </p>
      <div>
        <Button
          onClick={() => navigate('/acceso', { state: { from: location }, replace: false })}
        >
          Sign in again
        </Button>
        <Button
          variant="secondary"
          onClick={() => {
            dispatch(signedOut());
            navigate('/', { replace: true });
          }}
        >
          Log out
        </Button>
      </div>
    </Modal>
  );
}

export default SessionExpiredNotice;

SessionExpiredNotice is placed in Layout, next to NoticeList, so it's available on any screen. And SignInPage dispatches sessionRenewed instead of signedIn when it came from an expiry, so expired goes back to false and the redirect uses the state.from the modal left behind.

What this design gains you:

Before Now
Immediate expulsion on receiving a 401 A dialog explaining what happened
The half-written form is lost It stays mounted behind the dialog
Five failed requests, five sign-outs One idempotent action
The data layer navigates The data layer only reports
No way to return to what you were doing state.from takes you back to exactly that spot

And the essential clarification, because it's the mental trap of this whole section: an expired session is never decided on the client. It's detected when the server says so, with a 401, and no date check in the browser substitutes for that. If the client trusted its own clock to decide the token was still valid, setting it forward would be enough to bypass the expiry.

Conclusion

CicloUrbano is now a real application. Data comes from the network, gets cached, invalidated, and displayed; the session exists and survives a reload; filters live in the URL and are shareable; and every error has an owner who knows how to show it.

What's left first is the state-assignment table, applied piece of data by piece of data, the one that answers in advance the question that comes up most often in a React project. Its two critical rows: remote resources don't go in Redux, and a mutation's state isn't duplicated in a useState.

Below that is the data access layer, src/api/, with a rule that holds without exception: it speaks HTTP and knows nothing about React. The request wrapper centralizes the base URL, the headers, the response.ok check — because fetch does not throw on a 500 —, the 204 case, the timeout with AbortController combined with Query's signal, and an ApiError class that keeps the status code so that, further up, a 404 can be told apart from a 500 or a network failure. In exchange for that discipline, you get to test it without mounting anything, reuse it outside React, and have a single point to add real authentication.

In TanStack Query, a set of defaults gets fixed that aren't the manual's but this project's: a staleTime of 30 s to avoid firing a storm of requests while navigating, gcTime of 5 min, revalidation on regaining focus, a retry function that doesn't retry 4xx errors because the response isn't going to change, and retry: false on mutations because a retried POST can create two bookings. On top of that, the hierarchical key factory, which makes invalidating the parent reach the children by prefix, and the project's hooks with enabled to express "this depends on something I don't have yet" and with the signal that cancels the request on unmount.

From wiring it up to the UI, three ideas that hold for any screen: isPending renders the skeleton and isFetching only whispers, because replacing the list on every revalidation is an unjustified flicker; the filter that's part of the key goes to the server, and the text search is resolved client-side; and the result count carries aria-live so the change gets announced.

The create-a-booking mutation makes the split clear: validation runs against the cache's real data, invalidation lives in the hook because it's a consequence of the domain, and the notice and the redirect live in the page because they're decisions specific to that screen; the redirect uses replace so the back button doesn't return to an already-submitted form, and a mutation's error never knocks you out of the screen. Optimistic updates for confirming and cancelling contribute their three non-negotiable steps — cancelQueries so an in-flight query doesn't overwrite the change, the snapshot to make rollback possible, and onSettled to end up syncing with the server — and their rule for when to apply: yes for single-field changes with a predictable result, no for creations, and never for operations involving money.

On the client side, Redux keeps the session — persisted through a middleware that sees every action no matter what's mounted — and the catalogue's search term and sort order, with an explicit list of what was deliberately left out. Context handles theme and notices with the split-context pattern, so that whoever only emits notices never re-renders when they appear. The URL holds the filter and gives you four things for free: a shareable link, a faithful reload, a coherent back button, and a cache key; with the reminder that the URL is user input and has to be sanitized.

The protected routes end up wired to the real session, with the loading state that prevents the expulsion flicker for the day the session gets validated against the server, with RequireRole sending you to "forbidden" and not to "sign-in" — because the problem isn't with the session — and with the warning that always needs repeating: this is a UI convenience, not security; authorization is checked on the server, on every request.

And the lesson closes with the error decision table, which answers the question of who shows what: the screen itself for a query failure, with a retry; its own message for a 404; the * route for a nonexistent URL; errorElement for a rendering exception; ErrorBoundary for whatever happens outside the router; and an inline notice for a mutation's failure, without losing what was entered. The principle that orders it: the more localized the error, the more localized the response must be, and in no case a blank screen.

All of this has been checked by hand, with twenty steps and the network tab open. And by hand is exactly the problem: tomorrow someone changes one line in onSettled, and no one will repeat the twenty steps. Testing the Project turns that manual check into an automatic safety net: the test plan with 11-01's stories broken down by level, the unit tests for validateBooking and the reducers, the component tests on BikeCard and BookingForm, the integration tests with MSW over entire pages, the three Cypress flows, coverage read with judgment, full continuous integration, and — the definitive argument — a guided regression in which this lesson's invalidation is deliberately broken to see exactly which test catches it and which doesn't.

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