Data Fetching Patterns
Fetching data is more than calling fetch: it means handling loading, error and empty states, caching and deduplication, pagination, and deciding whether data is fetched on the client or the server. TanStack Query and fetch-then-render are the two dominant patterns.
What is this?
Data fetching patterns determine when, where and how an app loads remote data. The raw approach is fetch-in-effect: call the API in useEffect, store the result in state, and manage loading/error flags yourself. Library-based approaches such as TanStack Query and SWR add caching, background refetching, request deduplication and pagination out of the box. On the server, patterns like React Server Components, getServerSideProps and ISR decide whether data is fetched per request or baked in at build time.
Why do you need it?
- Loading, error and empty states are user-visible; a half-baked fetch leaves the UI blank or frozen.
- Caching and deduplication stop identical requests from firing for every component that needs the same data.
- Stale-while-revalidate gives instant renders from cache while refreshing data in the background.
- Choosing client vs server fetching controls SEO, TTFB, bundle size and how fresh the data is.
- Pagination and infinite scroll keep large datasets usable instead of shipping every row at once.
Mental model
Fetching groceries with a pantry
A caching data-fetching library
You check the pantry (cache) before shopping, buy only what is missing, and restock in the background so guests never wait. Without the pantry, you run to the store every time someone asks for milk.
How it works
- A query key (like ['items', page]) identifies a cache entry; the library checks the cache before hitting the network.
- Concurrent components that share a key are deduplicated: only one request is sent and everyone receives the same promise.
- Stale-while-revalidate returns cached data immediately, then refetches in the background and updates the UI.
- The query lifecycle is observed: a result is 'loading', 'success', 'error' or 'stale' based on refetch intervals, mutations and window focus.
- Mutations update the server, then invalidate or optimistically patch the cache so the UI stays consistent.
The TanStack Query request lifecycle.
Key commands
npm install @tanstack/react-queryAdd the React data-fetching library.
added 8 packages ...
npm install swrAlternative fetch+cache library from Vercel with the same stale-while-revalidate idea.
npm install @tanstack/react-query-devtoolsInstall the devtools so you can inspect the cache and query states.
npx react-query@latest query --data-fetchingScaffold a runnable TanStack Query example.
npm run devStart the app so you can observe queries in the devtools panel.
Examples
Fetching with TanStack Query
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" const fetchItems = async () => { const res = await fetch("/api/items") if (!res.ok) throw new Error("Failed to load items") return res.json()} function Items() { const { data, isLoading, isError, error } = useQuery({ queryKey: ["items"], queryFn: fetchItems, staleTime: 60_000, }) if (isLoading) return <p>Loading…</p> if (isError) return <p role="alert">{error.message}</p> return data.map((item) => <li key={item.id}>{item.name}</li>)}useQuery keys the cache with ['items'], fetches through queryFn, and exposes isLoading/isError/data. staleTime keeps data fresh for a minute, so remounting the component reuses the cache instead of refetching. A mutation later invalidates the key so the list updates after a write.
In production
- Dashboards use staleTime + refetchInterval to keep metrics current without spamming the API.
- Search-as-you-type debounces input and cancels the previous request so only the latest query wins.
- Infinite queries power social feeds and product lists, appending pages as the user scrolls.
- Optimistic mutations make likes and cart updates feel instant while the server syncs in the background.
Best practices
- Give every query a stable, descriptive key; keys are the cache's identity — ['item', id] not ['item'].concat(id).
- Centralize API calls in a fetcher module so error handling, timeouts and auth headers live in one place.
- Set staleTime and gcTime deliberately instead of relying on defaults (0 and 5 minutes).
- Always handle the empty-data case: a successful 200 with zero items is a different UI than an error.
- For SSR frameworks, prefer streaming/server fetching for first paint and use client queries for interactivity.
Common mistakes
- Calling fetch directly inside a component render (runs on every render) instead of inside useQuery or an effect.
- Forgetting AbortController, so stale responses overwrite newer ones in race conditions.
- Turning off refetchOnWindowFocus globally without thinking about freshness requirements.
- Embedding the API base URL in every call, making it impossible to switch environments later.
Common issues
Error: Failed to load items (thrown from !res.ok)fetch resolves for 404/500; the code forgot to throw when res.ok is false.
Check res.ok after every fetch and throw with the status so the query lands in the error state.
UI shows old data after a mutationThe cache was never invalidated after the write.
Call queryClient.invalidateQueries({ queryKey: ['items'] }) after the mutation succeeds.
Infinite loop of network requestsThe fetcher function is recreated with a new identity on every render, or queryKey changes each render.
Make queryKey a stable constant/array and keep the fetcher outside the component or in useCallback.
Debugging tips
- Open the TanStack Query devtools to inspect each query's state (loading/success/error/stale) and cache age.
- Check the Network tab: if you expected one request but see many, the query key is unstable or no cache exists.
- Log the error object — TanStack Query wraps thrown errors in Error, and HTTP errors only appear if you throw on !res.ok.
- Use queryClient.removeQueries or invalidateQueries to force a refetch while debugging a stale-cache bug.
Interview checkpoint
Hands-on lab
- Wire TanStack Query into a React app
- Create a query with loading, error and empty states
- Add a mutation that invalidates the list cache
- Observe deduplication and stale-while-revalidate behavior
Build a Cached Data-Fetching Layer
45 min · intermediate
Quick quiz
What is the primary job of the queryKey in TanStack Query?
fetch resolves successfully even when the server responds with a 500 status code.
After a successful POST to create an item, the list still shows the old data. What is missing?
Cheat sheet
useQuery({ queryKey, queryFn, staleTime })Fetch + cache a resourceuseMutation(mutationFn)Perform a writequeryClient.invalidateQueries({ queryKey })Refetch after a mutationuseInfiniteQueryPaginated/infinite datastaleTime / gcTimeFreshness vs cache retentionrefetchOnWindowFocusBackground refresh on tab focus