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

  1. Why remote data isn't application state
  2. Everything you have to solve by hand
  3. The fake API: json-server and db.json
  4. TanStack Query v5: installation and setup
  5. useQuery: the query and what it returns
  6. Query keys: the cache's identity
  7. The lifecycle of a cached piece of data
  8. Retries, focus revalidation, and pagination
  9. Mutations with useMutation and invalidation
  10. Optimistic updates and their rollback
  11. From useFetchBikes to useBikes
  12. How it coexists with Redux: the final architecture
  13. Alternatives: RTK Query, SWR, and React Router's loaders

  1. 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-002 is disponible.
  • 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 useState answer "what's the value?" A server-state cache also answers "is it still true?"

  1. 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.

  1. The fake API: json-server and db.json

First 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.

npm install --save-dev json-server

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:

npx json-server db.json --port 3001

And add the shortcut to package.json:

{
  "scripts": {
    "dev": "vite",
    "api": "json-server db.json --port 3001",
    "build": "vite build"
  }
}

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.

  1. TanStack Query v5: installation and setup

npm install @tanstack/react-query

And, optionally but strongly recommended, the devtools:

npm install --save-dev @tanstack/react-query-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.

  1. useQuery: the query and what it returns

import { 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.

  1. 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 bookings

Rules for designing keys:

  1. General to specific, like a route. This is what lets you invalidate by prefix (section 9).
  2. Anything that changes the result belongs in the key. If queryFn uses stationId, that id must be in the key; otherwise switching stations would show the previous one's data.
  3. 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.
  4. 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.

  1. 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.

  1. 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.

  1. Mutations with useMutation and invalidation

Queries 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 descendants

This 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.

  1. 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.

  1. From useFetchBikes to useBikes

Time 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:

  1. Navigating quickly between two stations and seeing the wrong station's fleet.
  2. Opening two panels that show the same bikes and firing two identical requests.
  3. Returning to a screen and waiting again for data you already had.
  4. Creating a booking and having the catalogue keep showing the bike as available.
  5. Losing the connection, getting it back, and being stuck with ten-minute-old data.
  6. 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.

  1. 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.

  1. Alternatives: RTK Query, SWR, and React Router's loaders

TanStack 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. A loader loads 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: the loader prefetches the query into the queryClient, and the component reads it with useQuery, 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:

  1. mutationFn doesn't check response.ok and is missing the Content-Type header. Without the check, a 500 is treated as a success and the rollback never happens; without the header, json-server might not parse the body.
  2. onMutate mutates the cache's objects. b.status = 'cancelada' modifies the original object inside map, so the previous "snapshot" ends up altered too: rolling back would be impossible even with an onError.
  3. cancelQueries and onError are both missing. There's no rollback, and an in-flight revalidation can overwrite the optimistic change.
  4. invalidateQueries() with no key invalidates every query in the app, causing an unnecessary full reload. And it should go in onSettled, not onSuccess, 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 queryKeythe 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

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved