Lesson 14 · Ecosystem
Data fetching
Why fetch in useEffect isn’t enough, TanStack Query, Suspense and use().
Loading lesson…
Lesson 14 · Ecosystem
Why fetch in useEffect isn’t enough, TanStack Query, Suspense and use().
Loading lesson…
Imagine an office where five people need to know a supplier's current prices. Each of them calls on their own and copies the price list into their notepad. The supplier gets five identical phone calls. A week later the supplier changes the prices – and the office has five notepads with old prices that nobody knows are old.
That's the essence of server data: it isn't yours. You only have a copy of it, which can go stale at any time. Local state (an open menu, the text in a field) is your own notebook – you know exactly what's in it. Server data is a copied price list. So it needs things local state doesn't:
That's why a better office has a noticeboard with a keeper. Whoever needs the price list looks at the noticeboard. The keeper calls the supplier only once, knows how old each piece of information is, and calls again when needed. In React, that keeper is the TanStack Query library.
From the lesson on effects you know how to load a user like this:
function UserName({ id }: { id: number }) {const [user, setUser] = useState<User | null>(null)useEffect(() => {let ignore = falsefetchUser(id).then((u) => !ignore && setUser(u))return () => { ignore = true }}, [id])return <span>{user ? user.name : 'Loading…'}</span>}
It works, but each component is that person with their own notepad:
UserName is on the page three times with the same id, three identical requests go out.What you see: On the left, three components that load the same user with useEffect. On the right, three that load it with useQuery. At the bottom, counters of real requests to the server.
Try it:
The takeaway: Loading by hand has no shared memory. useQuery shares one copy, merges identical requests and keeps the data even for components that appear later.
Loading the interactive part…
// main.tsx – once for the whole app: we put up the noticeboardconst queryClient = new QueryClient()<QueryClientProvider client={queryClient}><App /></QueryClientProvider>// anywhere in the appfunction UserName({ id }: { id: number }) {const { data, isPending, isError, error } = useQuery({queryKey: ['user', id], // the label: exactly what I wantqueryFn: () => fetchUser(id), // how to get it when it isn't on the noticeboardstaleTime: 60_000, // the information is fresh for a minute})if (isPending) return <span>Loading…</span>if (isError) return <span>{error.message}</span>return <span>{data.name}</span>}
What happens when three UserNames with id = 2 appear on the page:
['user', 2]. There's nothing on the noticeboard → the keeper calls queryFn. The component is in the isPending state.staleTime, the keeper also refreshes it in the background.The keeper looks up data on the noticeboard by its label. So it must contain everything the query depends on. The fruit price list, page 2 is a different folder from the vegetable price list, page 1:
useQuery({queryKey: ['products', { category, page }], // ✅ a change of category or page = a different folderqueryFn: () => fetchProducts({ category, page }),})useQuery({queryKey: ['products'], // ❌ all the pages would share one folderqueryFn: () => fetchProducts({ category, page }),})
| Term | Meaning |
|---|---|
queryKey | The label on the folder. Contains everything queryFn depends on. |
staleTime | How long the information is fresh (default 0 → refreshed on every mount or return to the window). |
gcTime | How long the noticeboard keeps data nobody uses (default 5 min). |
isPending | We don’t have any data yet (the first load). |
isFetching | A request is running right now – even in the background when we already have data. |
placeholderData | What to show before the data arrives – e.g. keepPreviousData when paging. |
When you call the supplier yourself and order a change, you know the copy of the price list on the noticeboard is no longer valid. You cross it out, and the keeper gets a new one. That's exactly what the pair useMutation + invalidateQueries does:
function NewPost() {const queryClient = useQueryClient()const mutation = useMutation({mutationFn: createPost, // a change on the serveronSuccess: () => {// cross out all the folders starting with 'posts'return queryClient.invalidateQueries({ queryKey: ['posts'] })},})return (<button onClick={() => mutation.mutate('A new post')} disabled={mutation.isPending}>{mutation.isPending ? 'Saving…' : 'Add'}</button>)}
mutation.mutate(…) calls createPost. While waiting, isPending is true.invalidateQueries marks the ['posts', …] folders as stale – all the pages of the list at once.What you see: At the top, a form for a new post (a mutation), at the bottom a paged list (a query with ['posts', { page }]). While data is being refreshed, "🔄 updating…" shows.
Try it:
keepPreviousData nothing flashes.posts folders.The takeaway: Queries read from the noticeboard, mutations change the server and cross out old copies. The keeper takes care of the rest.
Loading the interactive part…
In a restaurant every table can report "I don't have my soup yet", "I don't have my main course yet"… Or the whole section gets a sign saying "being prepared, please wait" and the dishes are brought when they're ready. The second way is clearer – the waiter handles the waiting in one place.
Instead of if (isPending) in every component, you can describe the waiting once, one level up: wrap part of the tree in <Suspense fallback>. The components inside then simply "have" the data.
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {const user = use(userPromise) // here we already have the data – no isPending, no undefinedreturn <h2>{user.name}</h2>}<ErrorBoundary fallback={<p>User not found</p>}><Suspense fallback={<p>Being prepared…</p>}><UserProfile userPromise={getUser(id)} /></Suspense></ErrorBoundary>
use(userPromise) checks whether the Promise is done. If not, the component says "still waiting" and React shows the nearest fallback.use returns the data.What you see: Buttons #1–#5 and the profile of the selected user. The profile uses use(promise), the waiting is handled by Suspense, errors by an error boundary. Switching is wrapped in startTransition.
Try it:
The takeaway: The component with the data handles neither waiting nor errors. Suspense describes the waiting, the error boundary the errors – one level up and in one place.
Loading the interactive part…
| Approach | When |
|---|---|
useQuery | The default choice. You handle waiting and errors in the component. |
useSuspenseQuery | The same with TanStack Query, but Suspense handles the waiting and an error boundary the errors. data is never undefined. |
use(promise) | The Promise comes from outside (a router loader, a Server Component, your own cache). |
lazy(() => import()) | Lazy loading of a component’s code – also through Suspense. |
Load the product list with TanStack Query. The loading state and the list are finished.
CHALLENGE: Your first useQuery
ProductNames doesn't load any data yet – the data is empty.
1. Replace the two TODO lines with one call:
const { data, isPending } = useQuery({ queryKey: ['products'], queryFn: () => fetchProducts() })
The loading state and the list are already written.Loading the interactive part…
The first challenge is the move from section 1 to section 2: rewrite loading from an effect to useQuery and add prefetching. The second extends section 3 with an optimistic change right on the noticeboard – with a backup in case the server rejects the change.
You'll see how much code disappears – and how many features you gain.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
The complete recipe for an optimistic update with a rollback – exactly as in the TanStack Query documentation.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
queryKey is the label and contains everything the query depends on; staleTime says how long the copy stays fresh.QueryClient, QueryClientProvider, useQuery, fetchProducts)QueryClient, QueryClientProvider, useQuery – from the library @tanstack/react-query: the data fetching and caching library (lesson 14).fetchProducts – from the file src/course/fakeApi.en.ts. The course fake API: data and functions pretending to be a server (with a delay, sometimes even with an error). In a real app you would call fetch() here.export interface Product {id: numbername: stringcategory: 'fruit' | 'vegetables' | 'bakery' | 'dairy'price: numberinStock: boolean}export async function fetchProducts(query = '', signal?: AbortSignal): Promise<Product[]> {await sleep(randomDelay())signal?.throwIfAborted()const q = query.trim().toLowerCase()return products.filter((p) => p.name.toLowerCase().includes(q))}
row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).