Server State with React Query

Course 3 · Ch 2
Server State with React Query
The useFetch hook from Intermediate Chapter 6, properly automated — caching, refetching, and race conditions handled for you

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

// npm install @tanstack/react-query import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const queryClient = new QueryClient(); function App() { return ( <QueryClientProvider client={queryClient}> <Dashboard /> </QueryClientProvider> ); }

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

import { useQuery } from "@tanstack/react-query"; function ProductDetail({ id }) { const { data, isLoading, isError } = useQuery({ queryKey: ["product", id], queryFn: () => fetch(`/api/products/${id}`).then((r) => r.json()), }); if (isLoading) return <p>Loading...</p>; if (isError) return <p>Something went wrong.</p>; return <h2>{data.name}</h2>; }

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.

useQuery({ queryKey: ["coins"], queryFn: fetchCoins, staleTime: 30000, // treat cached data as fresh for 30 seconds refetchInterval: 60000, // also refetch automatically every 60 seconds });

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

import { useMutation, useQueryClient } from "@tanstack/react-query"; function AddTodoForm() { const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: (newTodo) => fetch("/api/todos", { method: "POST", body: JSON.stringify(newTodo) }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["todos"] }); }, }); return ( <button onClick={() => mutation.mutate({ text: "New todo" })}> Add </button> ); }

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.

Don't copy query data into useState
Copying 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 + fetchuseQuery
Manual status stateisLoading / isError / isSuccess
Ignore-flag race condition fixPer-key caching, automatic
Manually re-calling setData after a POSTuseMutation + invalidateQueries

Coding Challenges

Challenge 1

Build a component fetching a list from any free public API using useQuery, handling isLoading and isError, and rendering the list on success.

📄 View solution
Challenge 2

Recreate 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 solution
Challenge 3

Build 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 solution

Chapter 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 queryKey share 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 separate useState — it reintroduces staleness
  • Next chapter: code splitting and Suspense — loading parts of an app only when actually needed