The same warning has run through the whole module in different words: the bikes field lives in catalogueSlice on a provisional basis, and src/data/domain.js has spent six modules pretending to be a database. The time has come to settle it, and the claim that governs everything here is this: data coming from a server isn't application state. It doesn't belong to you, it goes stale without telling you, other people change it while you're looking at it, and it arrives late. Storing it in useState or in Redux makes you responsible for a very long list of problems — loading, error, cancellation, race conditions, duplicate requests, revalidation, invalidation, retries, pagination — that dedicated libraries have already solved. In this lesson you'll set up a fake API with json-server, learn TanStack Query v5 for real — queries, keys, the cache's lifecycle, mutations, and optimistic updates with rollback —, rewrite useFetchBikes as useBikes and compare the before and after, and settle CicloUrbano's final architecture: Redux for client state, Query for server state. This closes Module 7.
Contents
- Why remote data isn't application state
- Everything you have to solve by hand
- The fake API:
json-serveranddb.json - TanStack Query v5: installation and setup
useQuery: the query and what it returns- Query keys: the cache's identity
- The lifecycle of a cached piece of data
- Retries, focus revalidation, and pagination
- Mutations with
useMutationand invalidation - Optimistic updates and their rollback
- From
useFetchBikestouseBikes - How it coexists with Redux: the final architecture
- Alternatives: RTK Query, SWR, and React Router's
loaders
- Why remote data isn't application state
The distinction sounds philosophical and is entirely practical. Compare the two categories point by point.
| Client state | Server state | |
|---|---|---|
| Ownership | It's yours. Nobody else can change it | It belongs to the server. You hold a copy |
| Location | Lives in the browser | Lives in a remote database |
| Expiry | Never expires: valid until you change it | Expires on its own, without telling you |
| Synchronization | Not needed | Constant and never perfect |
| Who changes it | Only your code | Other users, other tabs, automated processes |
| When it's available | Immediately | After a wait that can fail |
| Examples | Theme, open modal, draft, filter | Bikes, stations, bookings, users |
A concrete CicloUrbano case that makes it all clear. bici-002 shows as alquilada on your screen. While you're looking at the catalogue, the user who had it returns it at the North Park station. At that instant:
- The server knows
bici-002isdisponible. - Your Redux store still says
alquilada. - Redux is working perfectly: it's storing exactly what you told it to store.
The problem isn't that Redux is failing — it's that this isn't a state-management problem, it's a cache-synchronization problem. And a cache asks questions a state store never does: is this data still valid? when should I ask for it again? what do I show while the new version arrives? how long do I keep it if nobody's looking at it?
Redux, context, and
useStateanswer "what's the value?" A server-state cache also answers "is it still true?"
- Everything you have to solve by hand
Here's the complete list of what you have to write yourself if you store remote data in useState or in a slice. Read all of it: it's the lesson's whole argument.
| Problem | What writing it by hand involves | Did you already solve it? |
|---|---|---|
| Loading state | A phase variable per resource, and rendering it | Yes, in 05-02 and in 07-04's loadState |
| Error state | Storing the message, telling types apart, rendering it | Yes |
| Cancellation | AbortController + passing signal to fetch |
Yes, in 05-02 |
| Race conditions | An ignore flag to discard stale responses |
Yes, in 05-02 |
| Duplicate requests | Making sure two components asking for the same thing don't fire two requests | Partially, with condition in 07-04 |
| Revalidating on tab focus | A visibilitychange or focus listener that refetches |
No |
| Revalidating on reconnect | An online listener |
No (useOnlineStatus only detected it) |
| Invalidation after a write | Knowing which queries a given write makes stale, and refetching them | No |
| Retries with backoff | Counter, exponential timer, limit | No |
| Cache shared across components | A global registry of data already fetched | No |
| Garbage-collecting unused data | Freeing memory for what nobody's looking at any more | No |
| Pagination without flicker | Keeping the previous page while the next one arrives | No |
| Stale data while revalidating | Showing the old data and updating without a blank screen | No |
| Optimistic updates and rollback | Applying the change before the response, undoing it if it fails | No |
The first four you already solved, and they took a whole lesson. The other ten are the ones nobody writes because they're a lot of work, and they're exactly what separates an app that "works" from one that feels fast and reliable.
And there's a hidden cost: every one of those problems has to be solved per resource. Bikes, stations, bookings, users, and incidents, each with its own loading, error, cancellation, and invalidation. It's when you multiply by five that the library stops being optional.
- The fake API:
json-server and db.json
json-server and db.jsonFirst things first: we need a real server. json-server spins up a complete REST API from a JSON file, without writing a single line of server code.
Create db.json at the project root, with CicloUrbano's canonical data:
{
"bicicletas": [
{ "id": "bici-001", "model": "Classic Urban", "type": "urbana", "status": "disponible", "stationId": "est-01", "pricePerHour": 2.5 },
{ "id": "bici-002", "model": "Electric Pro", "type": "electrica", "status": "alquilada", "stationId": "est-01", "pricePerHour": 4.0 },
{ "id": "bici-003", "model": "Cargo Max", "type": "carga", "status": "mantenimiento", "stationId": "est-02", "pricePerHour": 5.5 },
{ "id": "bici-004", "model": "Classic Urban", "type": "urbana", "status": "disponible", "stationId": "est-03", "pricePerHour": 2.5 },
{ "id": "bici-005", "model": "Electric Pro", "type": "electrica", "status": "disponible", "stationId": "est-02", "pricePerHour": 4.0 }
],
"estaciones": [
{ "id": "est-01", "name": "Main Square", "district": "Downtown", "docks": 20 },
{ "id": "est-02", "name": "North Park", "district": "North", "docks": 15 },
{ "id": "est-03", "name": "Central Station", "district": "Riverside", "docks": 30 }
],
"usuarios": [
{ "id": "usr-01", "name": "Ana Ribera", "email": "[email protected]", "role": "cliente" },
{ "id": "usr-02", "name": "Marc Solé", "email": "[email protected]", "role": "operario" }
],
"reservas": [
{ "id": "res-01", "bicicletaId": "bici-002", "user": "usr-01", "startDate": "2026-05-04T09:00", "hours": 2, "status": "activa" }
]
}Start it on port 3001, so it doesn't clash with Vite's 5173:
And add the shortcut to package.json:
From here on you need two terminals: npm run dev for the app and npm run api for the API.
What you get without writing any server code:
| Request | What it does |
|---|---|
GET /bicicletas |
All bikes |
GET /bicicletas/bici-002 |
One by id |
GET /bicicletas?stationId=est-01 |
Filter by field |
GET /bicicletas?type=electrica&status=disponible |
Several filters combined |
GET /bicicletas?_page=1&_per_page=2 |
Pagination |
GET /reservas?_sort=startDate |
Sorting |
POST /reservas |
Creates, with a JSON body |
PATCH /reservas/res-01 |
Updates only the sent fields |
PUT /reservas/res-01 |
Replaces the whole resource |
DELETE /reservas/res-01 |
Deletes |
json-server really writes to db.json. POSTs and PATCHes persist to the file, so you can reload the page and see the booking is still there. That's what makes it far more useful than an in-memory mock: it behaves like a real server, failures included if you send it something wrong.
A practical tip: add db.json to version control but expect it to change as you run the app. If you want to go back to the initial state, git checkout db.json.
- TanStack Query v5: installation and setup
And, optionally but strongly recommended, the devtools:
TanStack Query revolves around a QueryClient object, which is the cache: it stores data by key, knows when it expires, decides when to revalidate, and notifies subscribed components. It's the conceptual equivalent of the Redux store, for the other category of state.
// src/queries/queryClient.js
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30s: how long data is considered fresh
gcTime: 5 * 60_000, // 5 min with no consumers before it's freed
retry: 2, // two retries on failure
refetchOnWindowFocus: true
}
}
});// src/main.jsx — CicloUrbano's full architecture
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 isn't available right now" onLog={reportError}>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<Providers>
<RouterProvider router={router} />
</Providers>
</Provider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>
);On placement: QueryClientProvider sits outside the Redux Provider for the same reason the latter sits outside the router — any component might need it, including route error components — and because a Redux thunk might want to invalidate queries, while the reverse never happens. In practice both orders work; what you can't do is leave either one inside RouterProvider.
The QueryClient is created outside the component, in its own module. If you created it inside with new QueryClient() in a component's body, every render would create a fresh cache and you'd lose everything you'd stored.
useQuery: the query and what it returns
useQuery: the query and what it returnsimport { useQuery } from '@tanstack/react-query';
function StationList() {
const { data, isPending, isError, error, isFetching } = useQuery({
queryKey: ['stations'],
queryFn: async () => {
const response = await fetch('http://localhost:3001/estaciones');
if (!response.ok) throw new Error(`The server responded ${response.status}`);
return response.json();
}
});
if (isPending) return <LoadingIndicator message="Loading stations…" />;
if (isError) return <Notice tone="error" text={error.message} />;
return (
<>
{isFetching && <span aria-live="polite">Updating…</span>}
<ul>
{data.map((station) => (
<li key={station.id}>{station.name} · {station.district} · {station.docks} docks</li>
))}
</ul>
</>
);
}The two required parameters:
| Parameter | What it is |
|---|---|
queryKey |
An array that identifies this piece of data in the cache. It's the single most important part |
queryFn |
A function that returns a promise with the data, or throws if it fails |
Critical rule for queryFn: it must throw when the request doesn't go well. fetch doesn't throw on a 404 or a 500 — only on network failures — so the response.ok check with its throw is mandatory. Without it, Query will treat an error response as a success and cache the server's error message as if it were data.
What useQuery returns, with the properties you'll use every day:
| Property | What it means |
|---|---|
data |
The data, or undefined if there's none yet |
isPending |
true while there's no data yet: the first load |
isError |
true if the last request failed and there's no valid data |
error |
The error object thrown by queryFn |
isFetching |
true whenever a request is in flight, including background revalidations |
isSuccess |
true once there's data |
isStale |
true if the data is considered stale |
refetch() |
Forces a new request manually |
status |
The phase as a single string: pending, error, or success |
The distinction between isPending and isFetching is the one people get wrong most often, and it's exactly what makes Query feel fast:
isPending: there's nothing to show yet. This is when the full-screen loading indicator belongs.isFetching: there's data — maybe a bit stale — being refreshed in the background. Show the data and, at most, a discreet indicator.
If you use isFetching where isPending belongs, the screen will empty out every time Query revalidates, and you'll have turned its best feature into a flicker.
- Query keys: the cache's identity
The queryKey is the data's identity in the cache. Two components with the same key share the same cache entry, the same request, and the same data; with different keys, they're different data.
['stations'] // all stations
['stations', 'est-02'] // one specific station
['stations', 'est-02', 'incidents'] // its incidents
['bikes'] // all bikes
['bikes', { stationId: 'est-01' }] // the ones at one station
['bikes', { type: 'electrica', sort: 'precio' }] // filtered and sorted
['bookings', { user: 'usr-01' }] // one user's bookingsRules for designing keys:
- General to specific, like a route. This is what lets you invalidate by prefix (section 9).
- Anything that changes the result belongs in the key. If
queryFnusesstationId, that id must be in the key; otherwise switching stations would show the previous one's data. - Objects are compared by content, not by reference, and the order of an object's keys doesn't matter:
{ type: 'urbana', sort: 'precio' }and{ sort: 'precio', type: 'urbana' }are the same key. Arrays, on the other hand, are order-sensitive. - Centralize keys in a factory, so you don't hand-write them in twenty places:
// 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 }]
}
};With this factory, a typo in a key stops being possible, and renaming a resource becomes a one-file change.
The most visible consequence of a shared key is automatic deduplication: if Header, CataloguePage, and FleetSummary all request ['bikes'] at the same instant, only one request is made and all three get the same data. That problem, which in 07-04 needed a hand-written condition, simply doesn't exist here.
- The lifecycle of a cached piece of data
Here's the mental model you need to internalize. A piece of data in Query's cache moves through four states.
stateDiagram-v2
[*] --> Fetching: first useQuery with this key
Fetching --> Fresh: data arrives
Fresh --> Stale: staleTime elapses
Stale --> Fetching: a component mounts,<br/>focus returns, or it's invalidated
Fresh --> Inactive: last consumer unmounts
Stale --> Inactive: last consumer unmounts
Inactive --> Fresh: remounted (still fresh)
Inactive --> Fetching: remounted (already stale)
Inactive --> [*]: gcTime elapses and it's freed
| State | What it means | What Query does |
|---|---|---|
| Fresh | The data is considered valid | Fetches nothing, not even when another component mounts |
| Stale | It might have changed | Keeps showing it, and revalidates in the background when there's a reason to |
| Inactive | No mounted component is using it | Keeps it in memory in case it comes back |
| Garbage collected | gcTime elapsed while inactive |
Memory is freed |
The two options that govern all of this:
| Option | What it controls | Default |
|---|---|---|
staleTime |
How long the data is considered fresh | 0: stale immediately |
gcTime |
How long an inactive piece of data is kept before it's freed | 5 * 60_000 (5 minutes) |
staleTime's default is 0, and it surprises everyone. It means the data goes stale the moment it arrives, so Query will revalidate as soon as there's a reason to — mounting a component, returning to the tab. It's not a bug: it's a conservative value, because Query always keeps showing the cached data while it revalidates, so the user never sees a wait, only a silent update. Even so, in most apps it's worth raising it.
Typical values by data type:
| Data type | Suggested staleTime |
Reasoning |
|---|---|---|
| CicloUrbano's stations | 60 * 60_000 (1 h) |
Almost never change: name, district, docks |
| Bike catalogue | 30_000 (30 s) |
The status field changes with every rental |
| Real-time availability | 0 |
Must always be up to date |
| User's bookings | 60_000 (1 min) |
Change through the user's own actions |
| User profile | 5 * 60_000 |
Changes very rarely |
| Configuration data | Infinity |
Only changes with a deploy |
// staleTime per query, overriding the client's default
const { data } = useQuery({
queryKey: keys.stations.all(),
queryFn: getStations,
staleTime: 60 * 60_000 // one hour: stations don't move
});One distinction people often mix up: staleTime and gcTime measure different things. staleTime is "how much I trust this data"; gcTime is "how long I keep it once nobody's looking." A piece of data can be stale and still sit in memory for hours, which is why going back to a screen shows you the old data instantly while Query refreshes it behind the scenes. That's exactly the fast-app feeling we were after.
- Retries, focus revalidation, and pagination
Retries
By default, Query retries three times with exponential backoff before giving up on a query. It's adjustable per query:
const { data } = useQuery({
queryKey: keys.bikes.all(),
queryFn: getBikes,
retry: (attemptNumber, error) => {
// Retrying a 404 makes no sense: the resource doesn't exist
if (error.status === 404) return false;
return attemptNumber < 2;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000)
});Retrying a temporary network failure makes sense; retrying a 401 or a 404 doesn't, ever. Filtering by error type is what separates a useful retry from three pointless requests.
Automatic revalidation
Query revalidates stale data at four moments, all configurable:
| Option | When it revalidates | Default |
|---|---|---|
refetchOnMount |
When a component using that key mounts | true |
refetchOnWindowFocus |
When you return to the browser tab | true |
refetchOnReconnect |
When the connection comes back | true |
refetchInterval |
Every N milliseconds (polling) | Off |
The second one is the one that impresses people most the first time: you leave the tab, come back ten minutes later, and the data is already up to date without you doing anything. It's one of the ten problems from section 2's list that nobody writes by hand.
For an operator panel that needs to see the fleet almost live:
const { data } = useQuery({
queryKey: keys.bikes.list({ stationId }),
queryFn: () => getBikes({ stationId }),
refetchInterval: 15_000, // poll every 15s
refetchIntervalInBackground: false // but not while the tab isn't visible
});Pagination without flicker
When you change page, the key changes, so the new page's data doesn't exist yet and data would be undefined: the list would disappear and come back. placeholderData prevents that.
import { useQuery, keepPreviousData } from '@tanstack/react-query';
function PaginatedCatalogue() {
const [page, setPage] = useState(1);
const { data, isPending, isFetching, isPlaceholderData } = useQuery({
queryKey: keys.bikes.list({ page }),
queryFn: async () => {
const response = await fetch(
`http://localhost:3001/bicicletas?_page=${page}&_per_page=2`
);
if (!response.ok) throw new Error("Couldn't load the catalogue.");
return response.json();
},
placeholderData: keepPreviousData // keeps the previous page while the new one arrives
});
if (isPending) return <LoadingIndicator message="Loading the catalogue…" />;
return (
<>
<BikeList bikes={data.data} />
<button
type="button"
onClick={() => setPage((p) => p + 1)}
disabled={isPlaceholderData || isFetching}
>
Next
</button>
</>
);
}With keepPreviousData, clicking "Next" keeps the previous list visible, isPlaceholderData is true while that's happening, and the change feels instant. Without it, every page change is a flicker to a blank screen.
- Mutations with
useMutation and invalidation
useMutation and invalidationQueries read; mutations write. And a write raises a question a read never has: which cached data has just gone stale.
// src/queries/useCreateBooking.js
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { keys } from './keys.js';
export function useCreateBooking() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newBooking) => {
const response = await fetch('http://localhost:3001/reservas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newBooking)
});
if (!response.ok) throw new Error("Couldn't create the booking.");
return response.json();
},
onSuccess: (createdBooking) => {
// The bookings list has changed: mark it stale so it refetches
queryClient.invalidateQueries({ queryKey: keys.bookings.all() });
// And the bike has moved to 'alquilada': the catalogue too
queryClient.invalidateQueries({ queryKey: keys.bikes.all() });
}
});
}// Used in NewBookingPage
function NewBookingPage() {
const [draft, setDraft] = useState({ bicicletaId: '', startDate: '', hours: 1 });
const user = useSelector(selectUser); // client state: Redux
const { showNotice } = useNoticesActions();
const navigate = useNavigate();
const createBooking = useCreateBooking(); // server state: Query
function handleSubmit(event) {
event.preventDefault();
createBooking.mutate(
{ ...draft, user: user.id, status: 'activa' },
{
onSuccess: (booking) => {
showNotice('success', `Booking ${booking.id} created.`);
navigate('/reservas', { replace: true });
},
onError: (error) => showNotice('error', error.message)
}
);
}
return (
<form onSubmit={handleSubmit} aria-busy={createBooking.isPending}>
{/* … fields … */}
<button type="submit" disabled={createBooking.isPending}>
{createBooking.isPending ? 'Sending…' : 'Book'}
</button>
</form>
);
}What useMutation returns:
| Property | What it is |
|---|---|
mutate(variables, options) |
Fires the mutation. Returns no promise |
mutateAsync(variables) |
Same, but returns a promise you can await |
isPending |
true while the write is in flight |
isError, error |
The mutation's failure |
isSuccess, data |
The result returned by mutationFn |
reset() |
Clears the mutation's state |
invalidateQueries is the key piece, and it works by prefix:
queryClient.invalidateQueries({ queryKey: ['bikes'] });
// Invalidates ['bikes'], ['bikes', {stationId:'est-01'}],
// ['bikes','detail','bici-002']… every key starting with 'bikes'
queryClient.invalidateQueries({ queryKey: ['bikes', 'detail', 'bici-002'] });
// Just that one
queryClient.invalidateQueries({ queryKey: ['bikes'], exact: true });
// Only the exact key, without descendantsThis is where rule 1 from section 6 pays off: hierarchical keys from general to specific, because they're what let you invalidate an entire branch with one line.
What invalidating actually does: it marks the queries as stale and immediately refetches the active ones (with mounted components). Inactive ones will refetch the next time someone mounts them. It deletes nothing, so the user keeps seeing the previous data while the new one arrives.
- Optimistic updates and their rollback
Invalidating is correct but not instant: the user clicks "Confirm," waits for the PATCH to finish, waits for the refetch to finish, and only then sees the change. On a slow connection that's a good two seconds of nothing.
Optimistic updates apply the change to the cache before the server responds, and undo it if it fails. It's the pattern that makes good apps feel instant.
// src/queries/useConfirmBooking.js
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { keys } from './keys.js';
export function useConfirmBooking() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (bookingId) => {
const response = await fetch(`http://localhost:3001/reservas/${bookingId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'confirmada' })
});
if (!response.ok) throw new Error("Couldn't confirm the booking.");
return response.json();
},
// 1) BEFORE the request: apply the change to the cache
onMutate: async (bookingId) => {
// Cancel in-flight revalidations: otherwise they could overwrite the optimistic change
await queryClient.cancelQueries({ queryKey: keys.bookings.all() });
// Snapshot the current state so it can be rolled back
const previousBookings = queryClient.getQueryData(keys.bookings.all());
// Write the change to the cache, IMMUTABLY
queryClient.setQueryData(keys.bookings.all(), (previous = []) =>
previous.map((booking) =>
booking.id === bookingId ? { ...booking, status: 'confirmada' } : booking
)
);
// Whatever is returned here arrives as 'context' in onError and onSettled
return { previousBookings };
},
// 2) IF IT FAILS: restore the snapshot
onError: (error, bookingId, context) => {
if (context?.previousBookings) {
queryClient.setQueryData(keys.bookings.all(), context.previousBookings);
}
},
// 3) NO MATTER WHAT: sync with the server
onSettled: () => {
queryClient.invalidateQueries({ queryKey: keys.bookings.all() });
}
});
}sequenceDiagram
participant U as User
participant C as Query Cache
participant S as API :3001
U->>C: mutate('res-01')
Note over C: onMutate:<br/>cancel · snapshot ·<br/>write 'confirmada'
C-->>U: The UI already shows "confirmed" (0 ms)
C->>S: PATCH /reservas/res-01
alt Success
S-->>C: 200
Note over C: onSettled: invalidate<br/>and refetch to confirm
else Failure
S-->>C: 500
Note over C: onError: restore the snapshot
C-->>U: Back to "active" + error notice
end
The three steps are inseparable, and each one solves a different problem:
| Step | Without it, what happens |
|---|---|
cancelQueries in onMutate |
An in-flight revalidation finishes later and overwrites the optimistic change with the old data |
Saving previousBookings |
There's no way to roll back: if it fails, the UI keeps lying |
onError restoring it |
Same thing: the user thinks they confirmed something the server rejected |
onSettled invalidating |
The cache ends up with what you wrote, not with what the server returned, and they can differ |
When to use optimistic updates and when not to:
| Use it | Don't |
|---|---|
| Failure is very unlikely (marking a favorite, a "like") | The server enforces rules you can't anticipate |
| The rollback is easy to explain to the user | The change triggers side effects (charges, emails) |
| The change is local and visible | The result depends on data you don't have |
| The latency genuinely bothers people | A 200ms wait bothers nobody |
Confirming a booking is defensible; creating one, less so, because the server could reject it if someone else just rented that bike, and having a booking appear and disappear is worse than waiting half a second. That's why useCreateBooking invalidates and useConfirmBooking is optimistic.
- From
useFetchBikes to useBikes
useFetchBikes to useBikesTime for the moment of truth. This was the hook from 05-06, with everything you had to do by hand:
// src/hooks/useFetchBikes.js — THE 05-06 VERSION
import { useState, useEffect } from 'react';
export function useFetchBikes(stationId) {
const [bikes, setBikes] = useState([]);
const [phase, setPhase] = useState('idle');
const [error, setError] = useState(null);
useEffect(() => {
if (!stationId) {
setBikes([]);
setPhase('idle');
return;
}
const controller = new AbortController();
let ignore = false;
async function load() {
setPhase('loading');
setError(null);
try {
const response = await fetch(
`/api/estaciones/${stationId}/bicicletas`,
{ signal: controller.signal }
);
if (!response.ok) throw new Error(`The server responded ${response.status}`);
const data = await response.json();
if (!ignore) {
setBikes(data);
setPhase('success');
}
} catch (err) {
if (err.name === 'AbortError') return;
if (!ignore) {
setError(err.message);
setPhase('error');
}
}
}
load();
return () => {
ignore = true;
controller.abort();
};
}, [stationId]);
return { bikes, loading: phase === 'loading', error };
}And this is the version with Query:
// src/queries/useBikes.js
import { useQuery } from '@tanstack/react-query';
import { keys } from './keys.js';
async function getBikes({ stationId, signal }) {
const url = stationId
? `http://localhost:3001/bicicletas?stationId=${stationId}`
: 'http://localhost:3001/bicicletas';
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`The server responded ${response.status}`);
return response.json();
}
/**
* Catalogue bikes, optionally filtered by station.
* Returns useQuery's full result.
*/
export function useBikes(stationId) {
return useQuery({
queryKey: keys.bikes.list({ stationId }),
queryFn: ({ signal }) => getBikes({ stationId, signal }),
enabled: Boolean(stationId) || stationId === undefined,
staleTime: 30_000
});
}// The component, with the loading-vs-revalidation distinction done right
function FleetPanel({ stationId }) {
const { data: bikes, isPending, isError, error, isFetching } = useBikes(stationId);
if (isPending) return <LoadingIndicator message="Loading the fleet…" />;
if (isError) return <Notice tone="error" text={error.message} />;
return (
<Panel title={`Fleet (${bikes.length})`}>
{isFetching && <span aria-live="polite">Updating…</span>}
<BikeList bikes={bikes} />
</Panel>
);
}The before and after, no dressing up:
useFetchBikes (05-06) |
useBikes (Query) |
|
|---|---|---|
| Lines | ~45 | ~20, and 8 of those are the request itself |
| Loading state | Hand-written | Included |
| Error state | Hand-written | Included |
| Cancellation | AbortController by hand |
signal supplied by Query |
| Race conditions | ignore flag by hand |
Impossible by design |
| Shared cache | ❌ Doesn't exist | ✅ By key |
| Deduplication | ❌ Two components, two requests | ✅ One request |
| Revalidation on tab focus | ❌ | ✅ |
| Revalidation on reconnect | ❌ | ✅ |
| Retries | ❌ | ✅ Configurable |
| Previous data while revalidating | ❌ Blank screen | ✅ Shows the old data |
| Invalidation after a write | ❌ Impossible from outside | ✅ invalidateQueries |
| Garbage collection | ❌ | ✅ gcTime |
| Inspection tools | ❌ | ✅ Query DevTools |
Concrete bugs that disappear without writing a line:
- Navigating quickly between two stations and seeing the wrong station's fleet.
- Opening two panels that show the same bikes and firing two identical requests.
- Returning to a screen and waiting again for data you already had.
- Creating a booking and having the catalogue keep showing the bike as available.
- Losing the connection, getting it back, and being stuck with ten-minute-old data.
- Piling up in memory the data for every station visited during the session.
And an important architectural consequence: catalogueSlice loses bikes, loadState, and error, along with the createAsyncThunk fetchBikes and its three extraReducers. What's left is searchTerm and sort, genuine client state. This is exactly what was announced in 07-04 and 07-05: build it and then undo it, just like it happens in a real project when a server-state library gets introduced.
- How it coexists with Redux: the final architecture
The rule fits in one line: Redux (or context) for client state; TanStack Query for server state. They don't overlap, they don't compete, and there's no need to choose.
flowchart TD
subgraph SERVER["Server state · TanStack Query"]
Q1["['bikes', filters]"]
Q2["['stations']"]
Q3["['stations', id, 'incidents']"]
Q4["['bookings', {user}]"]
M1["useMutation<br/>create · confirm · cancel"]
end
subgraph CLIENT["Client state"]
R1["Redux · sessionSlice<br/>user · loading"]
R2["Redux · catalogueSlice<br/>searchTerm · sort"]
C1["Context · theme"]
C2["Context · notices"]
end
subgraph OTHER["Outside both"]
U1["URL · ?tipo="]
L1["Local useState<br/>modals · drafts · selection"]
end
SERVER --> V["CicloUrbano's components"]
CLIENT --> V
OTHER --> V
M1 -. "invalidateQueries" .-> Q1
M1 -. "invalidateQueries" .-> Q4
The final breakdown, piece by piece:
| Data | Where it lives | Why |
|---|---|---|
| Bikes, stations, bookings, users, incidents | TanStack Query | Server state: it expires, it's shared, it isn't yours |
Session user, loading |
Redux sessionSlice |
Client state: who you are in this tab |
| Search term, sort | Redux catalogueSlice |
This session's preferences, shared across screens |
| Visual theme | ThemeContext |
Ambient, changes once per session |
| Notices | NoticesContext |
Purely visual, with state and actions kept separate |
?tipo= filter |
URL | Must be shareable via link |
| Modals, dropdowns, drafts, selection | Local useState |
Local UI and form state |
A nuance about the session worth thinking through. The user is a server resource — it lives at /usuarios — but which one of them you are in this tab is client state. A common and very clean split: identity (the token or the id) in Redux, and the profile data with useQuery(['users', id]). In CicloUrbano, with a fake sign-in, keeping the whole user in sessionSlice is perfectly reasonable.
How much Redux is left. After this move, CicloUrbano's store keeps sessionSlice and a catalogueSlice trimmed to two fields. bookingsSlice all but disappears: bookings are the server's, their transitions are mutations, and their business rules belong to the server — where they always should have lived. That's the honest conclusion 07-05 was building toward: in many real apps, once server state is where it belongs, the client state that's left fits in two contexts. It's a legitimate conclusion, and you can only reach it by having understood Redux, not by avoiding it.
- Alternatives: RTK Query, SWR, and React Router's
loaders
loadersTanStack Query isn't the only answer. These are the four serious options, compared.
| TanStack Query | RTK Query | SWR | React Router's loader |
|
|---|---|---|---|---|
| Package | @tanstack/react-query |
Included in RTK | swr |
Included in React Router |
| Requires Redux | No | Yes | No | No |
| Cache by key | Yes | Yes, generated from the endpoint | Yes | No: by route |
| Mutations and invalidation | useMutation + invalidateQueries |
Endpoints with tags |
Manual mutate |
action + automatic revalidation |
| Optimistic with rollback | Yes, onMutate/onError |
Yes, onQueryStarted |
Manual | Manual |
| Generated client | No, you write queryFn yourself |
Yes, from the API definition | No | No |
| Devtools | Query DevTools | Redux DevTools | Basic | React Router DevTools |
| Size | Medium | Already have it if you use RTK | Very small | No extra cost |
| Loads before painting | No (fetched on mount) | No | No | Yes: the route waits |
When to pick each one:
- TanStack Query: today's default choice. It's the most complete, the best documented, works with any way of fetching data, and doesn't force you into Redux. It's the one used in this lesson.
- RTK Query: if the project already uses Redux Toolkit. You define the API once —
endpoints,providesTags,invalidatesTags— and it generates the hooks for you; tag-based invalidation is more declarative than key-based invalidation. Everything also shows up in Redux DevTools, alongside the rest of the state. Its downside is that it ties you to Redux. - SWR: if you want the essentials — cache, focus revalidation, deduplication — with the smallest possible surface. Fewer features for mutations and pagination, but very solid and tiny.
- React Router's
loaders (06-03): solve a different, complementary problem. Aloaderloads the data before the route paints, removing the "mount → request → wait → paint" cascade and, with it, the loading flicker. Its limit is that the cache is per route, not per piece of data, so it doesn't dedupe across screens or revalidate on focus. Combining the two is the best architecture available today with React Router: theloaderprefetches the query into thequeryClient, and the component reads it withuseQuery, getting both early loading and caching.
// The combined pattern, as a one-line idea
export const stationLoader = (client) => async ({ params }) => {
// Puts the data in the cache before the route paints
await client.ensureQueryData({
queryKey: keys.stations.detail(params.estacionId),
queryFn: () => getStation(params.estacionId)
});
return null; // the component will read it with useQuery
};Common Mistakes and Tips
Mistake 1: queryFn not throwing on an HTTP error. fetch doesn't throw on a 404 or a 500. Without if (!response.ok) throw, Query will store the server's error message as if it were the data.
Mistake 2: using isFetching where isPending belongs. The screen will empty out on every revalidation, turning Query's best feature into a flicker.
Mistake 3: leaving out of the key a parameter queryFn uses. If stationId isn't in queryKey, switching stations shows the previous one's data. Anything that changes the result belongs in the key.
Mistake 4: creating the QueryClient inside a component. new QueryClient() in a component's body creates a fresh cache on every render. It belongs in its own module, outside.
Mistake 5: copying data into a useState or into Redux. It duplicates the source of truth and cancels out revalidation: your copy never finds out about anything. Use data directly.
Mistake 6: forgetting cancelQueries in an optimistic update. An in-flight revalidation can finish later and overwrite your change with the old data. It's an intermittent bug that's very hard to diagnose.
Mistake 7: mutating the cache inside setQueryData. The updater must return a new object, just like a reducer. Mutating the cache's object breaks reference comparison and components never find out.
Mistake 8: invalidating too much. invalidateQueries() with no key invalidates everything and triggers a storm of requests. Invalidate only the affected branch.
Tip 1: centralize keys in a factory. It eliminates typos, makes the hierarchy explicit, and turns renaming a resource into a one-file change.
Tip 2: tune staleTime per data type. The default of 0 is conservative. CicloUrbano's stations don't change: one hour is fine and saves dozens of requests.
Tip 3: use the Query DevTools from day one. They show every query with its key, its state — fresh, stale, inactive —, when it was last requested, and what data it holds. It's the equivalent of Redux DevTools for this layer.
Tip 4: wrap every query in its own hook. useBikes, useStations, useBookings, useCreateBooking. Components shouldn't see queryKey or queryFn, for exactly the same reason they shouldn't see the shape of Redux state.
Tip 5: don't cut db.json out of your workflow. Having an API that responds for real, that persists, and that sometimes fails is far more educational than a mock that always works.
Exercises
Exercise 1. Write the hooks useStations() and useStation(stationId) on top of the fake API, with hierarchical keys, a justified staleTime for each, and correct error handling. Then rewrite StationsPage to use them, correctly distinguishing the first load from a revalidation.
Exercise 2. This mutation hook has four problems. Find them and fix it.
export function useCancelBooking() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (bookingId) =>
fetch(`http://localhost:3001/reservas/${bookingId}`, {
method: 'PATCH',
body: JSON.stringify({ status: 'cancelada' })
}),
onMutate: (bookingId) => {
const previous = queryClient.getQueryData(['bookings']);
const updated = previous.map((b) => {
if (b.id === bookingId) b.status = 'cancelada';
return b;
});
queryClient.setQueryData(['bookings'], updated);
},
onSuccess: () => {
queryClient.invalidateQueries();
}
});
}Exercise 3. After this module, bookingsSlice is left almost empty. Decide what stays in Redux, what moves to TanStack Query, and what disappears entirely, for each of these, and justify each decision: (a) the ids array and the entities object; (b) loadState and error; (c) submitState; (d) the rule that only an active booking can be confirmed; (e) the createAsyncThunk submitBooking; (f) the selector selectBookingsSummary.
Solutions
Solution 1.
// src/queries/useStations.js
import { useQuery } from '@tanstack/react-query';
import { keys } from './keys.js';
const BASE = 'http://localhost:3001';
async function getJson(url, signal) {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`The server responded ${response.status}`);
return response.json();
}
export function useStations() {
return useQuery({
queryKey: keys.stations.all(),
queryFn: ({ signal }) => getJson(`${BASE}/estaciones`, signal),
// Name, district and docks don't change during a user's session
staleTime: 60 * 60_000
});
}
export function useStation(stationId) {
return useQuery({
queryKey: keys.stations.detail(stationId),
queryFn: ({ signal }) => getJson(`${BASE}/estaciones/${stationId}`, signal),
enabled: Boolean(stationId), // no id, no query fired
staleTime: 60 * 60_000
});
}// src/pages/StationsPage.jsx
function StationsPage() {
const { data: stations, isPending, isError, error, isFetching } = useStations();
const [district, setDistrict] = useState('todos'); // local UI state
if (isPending) return <LoadingIndicator message="Loading stations…" />;
if (isError) return <Notice tone="error" text={error.message} />;
// Derived: expressions, not state (07-01)
const visible = stations.filter((st) => district === 'todos' || st.district === district);
const totalDocks = visible.reduce((sum, st) => sum + st.docks, 0);
return (
<section>
<h1>Stations</h1>
{isFetching && <span aria-live="polite">Updating…</span>}
<p>{visible.length} stations · {totalDocks} docks</p>
<ul>
{visible.map((station) => (
<li key={station.id}><StationCard station={station} /></li>
))}
</ul>
</section>
);
}enabled: Boolean(stationId) replaces the if (!stationId) return; guard that in 05-06 had to be written inside the effect. And notice that this component solves 07-01's Exercise 2: no sync effects, no stored derived values, and server state right where it belongs.
Solution 2. The four problems:
mutationFndoesn't checkresponse.okand is missing theContent-Typeheader. Without the check, a 500 is treated as a success and the rollback never happens; without the header,json-servermight not parse the body.onMutatemutates the cache's objects.b.status = 'cancelada'modifies the original object insidemap, so theprevious"snapshot" ends up altered too: rolling back would be impossible even with anonError.cancelQueriesandonErrorare both missing. There's no rollback, and an in-flight revalidation can overwrite the optimistic change.invalidateQueries()with no key invalidates every query in the app, causing an unnecessary full reload. And it should go inonSettled, notonSuccess, to resync after a failure too.
export function useCancelBooking() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (bookingId) => {
const response = await fetch(`http://localhost:3001/reservas/${bookingId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, // 1)
body: JSON.stringify({ status: 'cancelada' })
});
if (!response.ok) throw new Error("Couldn't cancel the booking."); // 1)
return response.json();
},
onMutate: async (bookingId) => {
await queryClient.cancelQueries({ queryKey: keys.bookings.all() }); // 3)
const previousBookings = queryClient.getQueryData(keys.bookings.all());
queryClient.setQueryData(keys.bookings.all(), (previous = []) =>
previous.map((booking) => // 2) immutable
booking.id === bookingId ? { ...booking, status: 'cancelada' } : booking
)
);
return { previousBookings };
},
onError: (error, bookingId, context) => { // 3)
if (context?.previousBookings) {
queryClient.setQueryData(keys.bookings.all(), context.previousBookings);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: keys.bookings.all() }); // 4)
queryClient.invalidateQueries({ queryKey: keys.bikes.all() }); // frees up the bike
}
});
}Problem 2 is the most instructive one: an optimistic update written with mutation is worse than not having one, because it silently destroys the only copy that would have let you go back. Immutability isn't a Redux quirk; it's what makes undoing possible.
Solution 3.
| Item | Destination | Justification |
|---|---|---|
(a) ids and entities |
To Query; they disappear from Redux | They're the local copy of a remote resource. Query already stores bookings by key and keeps them in sync. Manual normalization stops being necessary: Query's cache is already indexed by key, and accessing by id just takes ['bookings', id] |
(b) loadState and error |
They disappear | They're useQuery's isPending, isError, and error. Keeping them would duplicate information Query already derives, with the risk that they contradict each other |
(c) submitState |
It disappears | It's useMutation's isPending, now scoped per mutation in flight instead of a single global variable shared by every form |
| (d) "only an active booking can be confirmed" | To the server, and on the client as an interface-level check | It's a business rule, and a business rule that only lives on the client protects nothing: it's the same lesson from 06-05 about authorization. On the client it's kept so you don't offer a button that's bound to fail (booking.status === 'activa' && <button>), but the one enforcing it is the PATCH |
(e) submitBooking |
To useMutation |
It's a remote write. Like useCreateBooking, with invalidateQueries for bookings and for bikes. It gains the invalidation the thunk didn't have |
(f) selectBookingsSummary |
Stays as a pure function, outside Redux | The calculation is still useful and still pure; what changes is where the data comes from. It becomes summarizeBookings(bookings) in src/utils/, called on useQuery's data and memoized with useMemo if the cost justifies it (08-03). The logic survives; the coupling to the store doesn't |
And the final tally: almost nothing is left of bookingsSlice. That doesn't mean lessons 07-03 through 07-05 were wasted time. The model of actions, pure reducers, normalized state, and selectors is the same one used in Zustand, in Jotai, in useReducer, and — literally — in the setQueryData you just wrote, which is a reducer by another name. What you've learned is to classify before choosing, and the best possible outcome of that classification is discovering you needed less than you thought.
Conclusion
Data coming from a server isn't application state: it doesn't belong to you, it goes stale on its own, other people change it while you're looking at it, and it arrives late. Storing it in useState or in Redux isn't wrong out of taste — it's wrong because it makes you responsible for a list of fourteen problems — loading, error, cancellation, race conditions, deduplication, revalidation on tab focus and on reconnect, invalidation after a write, retries, shared cache, garbage collection, pagination without flicker, stale data while revalidating, optimistic updates — multiplied by every resource. The first four cost you a whole lesson back in Module 5; the other ten are the ones nobody writes by hand.
You've set up a real fake API with json-server and a db.json holding CicloUrbano's canonical five bikes, three stations, two users, and one booking, served at http://localhost:3001 with filters, pagination, sorting, and writes that persist. On top of it, TanStack Query v5: a QueryClient created outside the components and provided in main.jsx, useQuery with its queryKey — the cache's identity, hierarchical from general to specific and centralized in a factory — and its queryFn, which must throw on an HTTP error because fetch doesn't. You can tell isPending apart from isFetching, which is what separates a screen that flickers from one that feels instant, and you know the lifecycle of a cached piece of data — fresh, stale, inactive, garbage collected — governed by staleTime ("how much I trust it") and gcTime ("how long I keep it once nobody's looking"), with values reasoned per data type: an hour for stations, thirty seconds for the catalogue, zero for live availability. And the features nobody writes by hand: retries filtered by error type, revalidation on tab focus and on reconnect, and keepPreviousData to paginate without emptying the list.
On the write side, useMutation with invalidateQueries by prefix — the direct payoff of hierarchical keys — and the full optimistic update: onMutate cancelling in-flight revalidations, saving the previous snapshot, and writing the change immutably; onError restoring that snapshot; onSettled resyncing no matter what. The three steps are inseparable, and an optimistic update written with mutation is worse than not having one. useFetchBikes has become useBikes: from forty-five lines to twenty, with six whole classes of bug that stop being possible and seven new features that didn't exist before.
CicloUrbano's final architecture is split with no overlap: TanStack Query for bikes, stations, bookings, and users; Redux for sessionSlice and a catalogueSlice trimmed to searchTerm and sort; context for the theme and the notices; the URL for the ?tipo= filter; and local useState for modals, drafts, and selections. And the honest conclusion this module has been building toward since 07-01: once server state is where it belongs, the client state that's left is far less than it looked. You only reach that conclusion by having understood Redux, not by avoiding it.
This closes Module 7. CicloUrbano now knows where every piece of data lives and why, has an auditable store with an action history, a cache that syncs itself with the server, and a clean split between what's its own and what belongs to the backend. It does a lot, and it does it well. What it still doesn't do is go fast: there are components repainting without needing to, selectors and calculations redone on every render, lists that repaint whole because a prop changed identity, and a final bundle the browser downloads in full before showing the first screen. Module 8: Performance Optimization takes that on directly: how to identify which renders are genuinely wasted before touching anything, React.memo to avoid repainting components, useMemo and useCallback — the debt this module has been leaving behind since 07-02 and 07-05 — to stabilize values and functions, code splitting and lazy loading so each screen only downloads what it needs, and the React DevTools Profiler to measure instead of guess. The next lesson is Performance Optimization Techniques in React.
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
