Server State with React Query
Intermediate Chapter 6 built a useFetch hook by hand: loading/error state, a manual response.ok check, and an ignore-flag to fix a genuine race condition. Project 7's dashboard used React Query's useQuery informally to skip writing all of that. This chapter explains exactly what it's doing underneath, and goes further — caching, automatic refetching, and updating server data with useMutation.
Setup, Recapped
One QueryClient, wrapping the app once — the same Provider shape seen with Context and Redux Toolkit, holding a cache shared by every useQuery call anywhere below it.
How queryKey Solves Chapter 6's Race Condition — Automatically
Including id directly inside queryKey — ["product", id] — means React Query treats every distinct id as a separate cache entry. Switching rapidly between two ids no longer risks the stale-overwrite bug from Chapter 6's challenge: each id's request and result are tracked independently by key, so a slow response for an old id can never land on top of a newer id's already-displayed data. No ignore flag, no AbortController, written by hand at all — the keying scheme itself prevents the problem from Chapter 6 entirely.
Caching in Practice
The first component to call useQuery({ queryKey: ["product", 5], ... }) triggers the actual fetch; if a second, completely unrelated component requests the exact same key later, React Query returns the already-cached result instantly, without firing a second network request at all — this is what made Project 7's badge-plus-detail-page style dashboard so much simpler than hand-rolling the same sharing logic with useFetch.
staleTime controls how long cached data is considered "still good enough" before React Query will bother refetching it on the next render of a component using that key; refetchInterval (used informally in Project 7) is for actively polling data that changes on its own over time, like live prices.
useMutation — Changing Server Data
useQuery is for reading server data; useMutation is for changing it — a POST/PUT/DELETE request triggered by calling mutation.mutate(...), typically from an event handler rather than automatically on render. onSuccess's call to queryClient.invalidateQueries({ queryKey: ["todos"] }) tells React Query that any cached data under that key is now out of date, triggering every component reading ["todos"] to refetch automatically — the standard way a mutation keeps the rest of the UI in sync with what just changed on the server.
data from useQuery into a separate useState "just to have a local copy" reintroduces exactly the staleness problems this whole chapter exists to avoid — that local copy won't update when the cache refreshes or a mutation invalidates it. Treat useQuery's return value as the live source of truth directly; if a derived value is needed, compute it inline or with useMemo (Intermediate Chapter 7), rather than storing a snapshot.
| Hand-rolled (Ch 6) | React Query equivalent |
|---|---|
| useState + useEffect + fetch | useQuery |
| Manual status state | isLoading / isError / isSuccess |
| Ignore-flag race condition fix | Per-key caching, automatic |
| Manually re-calling setData after a POST | useMutation + invalidateQueries |
Coding Challenges
Build a component fetching a list from any free public API using useQuery, handling isLoading and isError, and rendering the list on success.
📄 View solutionRecreate Intermediate Chapter 6 Challenge 2's race-condition scenario (two buttons switching between a slow and a fast id) using useQuery with the id included in queryKey, and confirm the stale-overwrite bug no longer happens — with no ignore flag written by hand.
📄 View solutionBuild a small todo list backed by useQuery (fetching the list) and useMutation (adding a new todo), with onSuccess calling invalidateQueries so the list refetches and shows the new item automatically.
📄 View solutionChapter 2 Quick Reference
- QueryClientProvider wraps the app; useQuery reads, useMutation writes
- queryKey identifies a cache entry — including variables (like an id) in it keys each one separately
- Two components using the same
queryKeyshare one cached result — no duplicate fetch - staleTime / refetchInterval control how fresh cached data needs to be, and automatic polling
- invalidateQueries after a mutation tells React Query to refetch affected data automatically
- Never copy
useQuery's data into a separateuseState— it reintroduces staleness - Next chapter: code splitting and Suspense — loading parts of an app only when actually needed