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
- The definitive table: what data lives where
- The data access layer:
src/api/client.js - Resource-specific functions
- Why this layer knows nothing about React
queryClient.jsand its defaults- The key factory
- The project's query hooks
- Connecting the catalogue: from toggles to real states
- The complete mutation: creating a booking
- Optimistic updates: confirming and cancelling
- Redux: the session
- Redux: the catalogue, and what doesn't belong in Redux
- Context: theme and notices
- The filter in the URL with
useSearchParams - Protected routes wired to the real session
- End-to-end error handling
- The final
main.jsx - The complete booking flow
- Manual walkthrough check
- 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
- The data access layer:
src/api/client.js
src/api/client.jsBefore 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 |
- 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.
- 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.
queryClient.js and its defaults
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
});
}
- 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.
- 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."
- 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:
- 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.
isPendingversusisFetching.isPendingmeans "there's no data yet" and renders the skeleton.isFetchingmeans "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.replace: truewhen 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.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.
- 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.
- 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 |
- 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.
- 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.
- 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.
- The filter in the URL with
useSearchParams
useSearchParamsIt'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=electricainto a chat takes whoever opens it to exactly that view. - Faithful reload:
F5doesn'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.
- 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.
- 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.
- The final
main.jsx
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.
- 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.
- 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
useStatewith auseEffect. This is the pattern TanStack Query exists to eliminate: no caching, no deduplication, race conditions, and half-baked error state. If auseEffectthat doesfetchshows up, something has gone wrong. - Duplicating
isPendingin its ownuseState. Produces the button that stays loading forever because the error branch forgot to set it back tofalse. The mutation's state already exists: use it. - Forgetting
cancelQueriesinonMutate. 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
POSTretried after a timed-out request can create two bookings.retry: falseon mutations, and a retry button for the person instead. - Retrying a 404. Three requests and three seconds to arrive at the same response. The
retryfunction 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
ProtectedRouteas 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=coheteproduces 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:
- 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. - Use
useMutationStateto keep track of pending mutations and apply all of them when recomputing the cache. - 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
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
