Start the request in useEffect with the request inputs in the dependency array, store the result in state, and render three states: loading, error and data. The cleanup flag (or AbortController) keeps an old response from overwriting a newer one.
Loading: true until the first response; render a skeleton or spinner.
Error: fetch does not reject on 404 or 500, so check res.ok yourself and throw.
Data: render it; also cover the empty case (an empty list is not an error).
Dependencies: userId in the array refetches when it changes; leaving it out shows stale data for the old user.
In practice: this is fine for learning and small apps; production code usually uses TanStack Query or the framework's loader, because this pattern has no cache, no deduplication and no retries.
ReactMiddle
Data Fetching
Fetching in effects and its pitfalls, TanStack Query, caching and invalidation, Suspense for data, pagination, optimistic updates, errors
Questions closed
0 / 21
- 0Review
- 7Asked often
Questions
TanStack Query (formerly React Query) is a cache for server state: you describe a request with a queryKey and a queryFn, and it handles loading and error state, caching, request deduplication, background refetching, retries and invalidation after mutations.
Cache by key: two components asking for ['user', 7] share one request and one cached result.
Stale-while-revalidate: cached data shows instantly, and a refetch runs in the background when it is stale.
Retries: failed queries retry 3 times with exponential backoff by default.
Mutations: useMutation plus invalidateQueries keeps lists in sync after a write.
Not a global store: it is for data the server owns; UI state (an open modal, a form draft) stays in React state or a client store.
fetch rejects only when no response arrives (a network failure, a CORS block, an abort); a 404 or 500 is still a valid HTTP response, so the promise resolves with res.ok === false. Axios rejects for any status outside 2xx, parses JSON for you and adds interceptors and a timeout.
**Check res.ok** (status 200 to 299) after every fetch and throw yourself, or an error page is parsed as data and TanStack Query treats the failure as a success.
JSON: fetch needs await res.json() and, for a request body, JSON.stringify plus a Content-Type header; axios does both.
Interceptors: axios has request and response interceptors (auth headers, token refresh, logging); with fetch that goes into a small wrapper every call uses.
Timeouts and cancelling: both accept an AbortSignal, and AbortSignal.timeout(10_000) gives fetch a timeout.
Cost: fetch is built into browsers and Node 18+; axios is an extra dependency, though it reports upload progress (onUploadProgress) and fetch does not.
Effects run after render, on the client only, once per mounted component, so hand-written fetching gets race conditions, no cache, duplicate requests and request waterfalls. Each is fixable by hand, which is exactly the code a data library or a framework loader already contains.
Race conditions: typing fast fires several requests and a slow early response can land last; you need an ignore flag or AbortController.
No cache: going back to a page refetches and shows a spinner again; two components needing the same data make two requests.
Waterfalls: a parent fetches, renders a child, which only then starts its own fetch; the latencies add up.
Late start: the request starts after JavaScript downloads and the first render, not while the page loads; SSR cannot run it.
StrictMode: in development the effect runs twice, so you see two requests; it is a check that your cleanup is correct, not a bug to suppress.
Boilerplate: loading, error, retry and refetch-on-focus logic repeated in every component.
The query key is the cache address: the same key means the same cached data. Put every input of queryFn (ids, filters, page) into the key, ordered from general to specific, so different inputs get different cache entries and invalidation can target a whole branch.
Hierarchy: ['todos'], ['todos', 'list', { status }], ['todos', 'detail', id]; then invalidateQueries({ queryKey: ['todos'] }) refreshes all of them by prefix.
Objects are fine: keys are hashed deterministically, so { status, page } matches regardless of property order.
Automatic refetch: when a variable in the key changes, the query switches to the new cache entry and fetches it; no dependency array needed.
Key factories: keep keys in one module with queryOptions, so the key and its queryFn cannot drift apart and are reused by useQuery, prefetchQuery and invalidateQueries.
ESLint: @tanstack/eslint-plugin-query has an exhaustive-deps rule that catches a variable used in queryFn but missing from the key.
page is used in queryFn but is missing from queryKey, so the cache entry is the same for every page: TanStack Query sees an unchanged key, keeps the cached page 1 and does not refetch. Add page to the key.
The key, not a dependency array, decides when a query refetches; a closure over a new page is never called for a key that is already fresh.
With page in the key, each page gets its own cache entry, so going back to page 1 is instant.
Add placeholderData: keepPreviousData to keep showing the previous page while the next one loads, instead of a spinner; isPlaceholderData tells you it is the old page.
The @tanstack/query/exhaustive-deps lint rule flags this exact mistake.
staleTime is how long fetched data counts as fresh: while fresh, it is served from cache with no refetch. gcTime is how long an unused query (no component observing it) stays in memory before it is removed. Defaults: staleTime 0, gcTime 5 minutes.
**staleTime: 0: data is stale right away, so every new mount, window focus or reconnect triggers a background refetch, while the cached data still shows instantly.
Raise staleTime** for data that changes rarely (a country list: Infinity; a profile: a few minutes); this is the main knob for cutting requests.
**gcTime only starts counting when the last observer unmounts; coming back within it shows cached data instantly instead of a spinner.
Naming**: gcTime was called cacheTime before v5; people mixed it up with staleTime, hence the rename.
SSR: set staleTime above 0 so the client does not refetch data the server has just rendered.
isPending means there is no data yet (status === 'pending'); isFetching means a request is running right now, including background refetches of data you already show; isLoading is both at once, the very first load.
Full-page spinner or skeleton: isPending, because there is nothing to render.
Small "refreshing" indicator: isFetching && !isPending, the data is on screen and being updated.
Disabled queries: a query with enabled: false and no cache stays isPending forever, which is why isLoading exists: it is false when nothing is being fetched.
History: in v4 isLoading meant what isPending means now; v5 renamed the loading status to pending.
Errors: check isError or error separately; a failed background refetch keeps the old data, so you can show both.
A stale query refetches in the background when a new component using it mounts, when the window regains focus and when the network reconnects; with refetchInterval it also polls. All of these only fire for stale data, so staleTime is the first thing to tune.
**refetchOnMount, refetchOnWindowFocus, refetchOnReconnect**: all true by default; set one to false or 'always' per query or in defaultOptions.
Window focus surprises: switching tabs in development triggers requests; that is the feature working, not a bug. Raise staleTime instead of disabling it everywhere.
Polling: refetchInterval: 5000 for live data; refetchIntervalInBackground keeps polling in hidden tabs (off by default).
Manual: refetch() from the hook, or queryClient.invalidateQueries() to mark data stale and refetch whatever is on screen.
React Native: there is no window focus, so wire focusManager and onlineManager to AppState and NetInfo.
Pass the first query's result into the second one's key and turn the second off until that value exists, with enabled: !!userId or, type-safely, queryFn: userId ? () => fetchProjects(userId) : skipToken. When the value arrives, the key changes and the second query starts.
**skipToken** (v5): TypeScript then knows userId is defined inside queryFn, no ! needed; enabled does not narrow types.
Status: a disabled query sits in isPending with fetchStatus: 'idle'; use isLoading if you want "really loading".
Cost: dependent queries are a waterfall by design; if the server can return both in one request, or the second does not really need the first, fetch them in parallel instead.
Several independent queries: useQueries runs a dynamic list of queries in parallel.
useMutation({ mutationFn }) runs a write and tracks its isPending, error and data; it does not touch the cache on its own. After success, either invalidate the affected queries so they refetch, or write the server's response straight into the cache with setQueryData.
**invalidateQueries**: the simple, safe default; it marks matching queries stale and refetches the ones on screen. Return its promise from onSuccess to keep the mutation pending until the fresh data is in.
**setQueryData: saves a request when the response already contains the updated entity; update the detail and patch the list.
mutate vs mutateAsync**: mutate handles errors through callbacks; mutateAsync returns a promise you must catch yourself.
Callbacks: onSuccess, onError and onSettled on useMutation run even if the component unmounted; the ones passed to mutate() do not.
The mutation invalidates ['projects', 42, 'todos'] with the number from the server's response, but the list is cached under ['projects', '42', 'todos'] with the string from the URL. Query keys match by value and type, so nothing is invalidated and the list is never refetched.
invalidateQueries compares the filter with each cached key element by element; '42' and 42 differ, and a filter that matches nothing fails silently.
The five-minute staleTime hides it further: without an invalidation the cached list counts as fresh, so remounting or refocusing the window does not refetch either. Only a reload (or waiting out the five minutes) shows the new todo.
Fix: build every key in one place (a key factory or queryOptions), typed so that projectId is always a string, and invalidate with exactly the value the query used.
String(todo.projectId) would also work, but it repeats the key's shape in a second place, which is how the mismatch got in.
To spot it: the TanStack Query Devtools show the cached key with "42" in quotes, still fresh after the mutation.
In onMutate, cancel in-flight queries for that key, snapshot the current cache, write the expected result with setQueryData and return the snapshot; in onError put the snapshot back; in onSettled invalidate so the cache ends up matching the server either way.
**Why cancelQueries: a refetch that started before the mutation would land after it and overwrite the optimistic value with old data.
Context**: whatever onMutate returns arrives as the third argument of onError and onSettled.
Simpler variant: skip the cache and render mutation.variables as a pending row while isPending; on error it simply disappears, and nothing needs rolling back. Good when only one component shows the change.
When not to: payments, irreversible actions, or writes that fail often; a wrong optimistic UI there is worse than a short wait.
React 19: useOptimistic does the same for state owned by React rather than by the query cache.
Use useInfiniteQuery: give it initialPageParam, a queryFn that reads pageParam, and getNextPageParam that returns the next cursor from the last page, or undefined when there is none. It stores all loaded pages in data.pages and gives you fetchNextPage, hasNextPage and isFetchingNextPage.
Cursor over offset: a cursor from the server (nextCursor) does not skip or duplicate items when rows are inserted while the user scrolls.
Trigger: call fetchNextPage() when a sentinel element at the bottom becomes visible (IntersectionObserver), guarded by hasNextPage && !isFetchingNextPage.
Refetch cost: a refetch reloads every loaded page in order, to keep cursors consistent; maxPages caps how many pages are kept.
Long lists: combine with virtualisation (TanStack Virtual) so thousands of rows do not stay in the DOM.
Plain pagination with page numbers is a normal useQuery with the page in the key and placeholderData: keepPreviousData.
Decide per error where it belongs: a failed first load replaces the content (inline message or an error boundary), a failed background refetch keeps the old data with a small notice, and a failed mutation shows next to the action. TanStack Query retries queries 3 times with exponential backoff before reporting an error.
Retries: retry and retryDelay per query; turn retries off for 4xx responses, which will not fix themselves (retry: (count, err) => ...). Mutations do not retry by default.
Error boundaries: throwOnError: true (or useSuspenseQuery, which always throws when there is no data) sends the error to the nearest boundary; QueryErrorResetBoundary lets its "Try again" button refetch.
Background errors: data and error can both be set; show the data with a "could not refresh" hint instead of blanking the screen.
Global reporting: QueryCache({ onError }) logs or toasts every query error once, instead of in each component.
**fetch gotcha**: it rejects only on network failure; check res.ok, or every 500 becomes "success" with an error body.
Pass an AbortSignal to fetch and abort it when the result is no longer wanted. In useEffect create an AbortController and call abort() in the cleanup; TanStack Query hands a signal to your queryFn and aborts it when the query is unmounted or its key changes.
Effect cleanup: runs when the dependencies change and on unmount, exactly the moments the old request becomes useless.
Ignore the abort error: an aborted fetch rejects with an AbortError (DOMException); do not show it as a failure.
TanStack Query: if queryFn does not consume signal, the request still completes and its result is cached; with it, a stale request is really cancelled.
Axios: also accepts signal in its config.
Saving bandwidth: cancelling stops the download and frees the connection; it does not undo work the server has already started.
Funnel every 401 through one shared refresh promise: the first failing request starts the refresh, the others await the same promise, and then each retries once with the new token. If the refresh itself fails, sign the user out instead of retrying.
Why one shared refresh
Five parallel requests that each refresh send five refresh calls. With refresh token rotation the first call invalidates the old refresh token, the other four present a used one, and a server with reuse detection treats that as theft and revokes the whole session.
The promise is cleared in finally, so the next expiry starts a new refresh.
Details that matter
Retry once: the isRetry flag stops an endless loop when the server keeps answering 401.
Late 401s: a request sent with the old token can fail after the refresh has already finished; compare the token it was sent with against the current one and simply retry if it changed.
The refresh call itself is never retried: a 401 there means the session is over.
TanStack Query: exclude 401 from retry, so the query does not retry on top of the fetch layer.
Around it
Refreshing shortly before exp avoids most 401s, but keep this path for clock skew and revoked tokens.
Tabs share the refresh cookie and can race each other; the Web Locks API (navigator.locks.request) lets one tab refresh at a time.
With axios the same logic lives in a response interceptor, with a retry flag on the request config.
A component that needs data it does not have yet suspends, and the nearest <Suspense> boundary shows its fallback until the data resolves; the component itself only ever renders the success case. You suspend through a Suspense-aware source: use(promise), useSuspenseQuery or a framework loader.
Declarative loading: placement of boundaries decides what shows a skeleton together; errors go to an error boundary above it.
**useSuspenseQuery**: data is always defined, no isPending branch; enabled and skipToken are not supported, so render the component only once its inputs exist.
**use(promise): the promise must be stable across renders (cached by a library or passed down from a Server Component), not created during render.
Transitions**: if a key change happens inside startTransition (or useDeferredValue), React keeps showing the old content instead of flashing the fallback again.
Parallel siblings: two suspending queries under one boundary start together only if they are siblings; one nested inside the other waits, a waterfall. useSuspenseQueries fetches several at once.
The promise is created during render, so every render passes use a brand new promise: React suspends, the request resolves, React re-renders, a new fetch starts and it suspends again. Promises given to use must be cached outside the render, by a library, a framework or a Server Component.
React warns about it in the console: "A component was suspended by an uncached promise".
On first mount nothing survives between those attempts: a suspended render throws away its state and useMemo cache, so useMemo inside the same component does not fix it.
Fixes: useSuspenseQuery (TanStack Query caches the promise by key), a promise created by a parent or a Server Component and passed as a prop, or a small module-level cache keyed by id.
In a Server Component the same intent is simply const user = await fetchUser(id).
A waterfall is a chain of requests where each one starts only after the previous one finished, usually because a component fetches, then renders a child that starts its own fetch. Three 200 ms requests in a chain cost 600 ms instead of 200. The fix is to start requests as early as possible and in parallel.
Where they come from
Fetch-on-render: each level of the tree waits for its parent's data before it even mounts.
Lazy routes: download the route's code, then render it, then start its data request.
Nested Suspense boundaries, where an inner suspending component is only rendered after the outer one resolves.
How to avoid them
Hoist: fetch at the route level (React Router loader, Next.js Server Components) so all requests start when navigation starts, before components render.
Parallelise: Promise.all, useQueries or useSuspenseQueries for independent data; siblings, not parents and children.
Prefetch: queryClient.prefetchQuery on hover or in the loader, then useQuery reads the warm cache.
Change the API: if the second request truly needs the first's result, a single endpoint or a server-side join removes a round trip.
How to see them
The browser's Network panel waterfall column, or the TanStack Query Devtools; the staircase pattern is unmistakable.
Create a QueryClient per request on the server, await queryClient.prefetchQuery(...) for what the page needs, and pass dehydrate(queryClient) to a <HydrationBoundary>; client components below it call the same useQuery and find the data already in the cache, with no loading state and no second request.
One client per request on the server, never a module-level singleton, or one user's data leaks into another user's page.
On the client, create the QueryClient once (in useState or a module-level browser singleton) inside a 'use client' provider.
**staleTime above 0: with the default 0, the client refetches right after hydration, doubling every request.
Same options**: share queryOptions between the server prefetch and the client hook so the keys match exactly.
Streaming: v5 can also dehydrate pending queries, so the page streams while slow queries finish.
When not to: if a Server Component can render the data itself and nothing on the client refetches or mutates it, skip the cache and just await it there.