skip to content

TanStack Query

TanStack Query manages server state rather than client state: caching, background refetching, mutations, invalidation, and paginated or infinite lists. The framing itself, that server state is not client state, is a common interview question in its own right.

on this pageshow

explore

questions

27

In TanStack Query v5, how do you make a query for a user's projects wait until another query has returned that user's id?

level: juniorimportance: must knowfreq 66%

answer

  1. a boolean option on the second query
  2. the id belongs in the key
  3. pending status, but nothing running
  4. a typed alternative to disabling

basics

~20 s

Give the projects query enabled: !!userId and put userId in its query key. While disabled it sits in status pending with fetchStatus idle, and it fetches on its own as soon as the id arrives.

solid answer

~40 s

The second `useQuery` takes `enabled: !!userId`, where `userId` comes from the first query's `data`. While `enabled` is false the query never runs: it reports `status: 'pending'` and `fetchStatus: 'idle'`. When the user query resolves and `userId` becomes defined, the option flips to true and the projects query fetches automatically - no effect, no manual `refetch`. `userId` must also be in the key, `['projects', userId]`, so each user gets its own cache entry. In TypeScript you can instead pass `skipToken` as the `queryFn` while the id is missing, which keeps the function's types honest. One trap: a spinner driven by `isPending` shows while the query is merely waiting; `isLoading` (`isPending && isFetching`) is true only while a request is really in flight.

go deeper

for a junior

Recall the two moves: enabled: !!userId on the second query and userId inside its query key. Be ready to say what status the disabled query shows.

for a middle

Explain status pending with fetchStatus idle, why isLoading and not isPending drives the spinner, and how skipToken differs from enabled: false for types and refetch.

for a senior

Show you handle the failure path: a first query that errors or yields no id leaves the dependent one waiting forever, so the UI must branch on the parent query too.

for a principal

Discuss when a dependent chain is acceptable and when the API should return both resources in one request, since a client-side dependency always costs a serial round trip.

## The pattern: a dependent query A **dependent** (or serial) query is one that cannot start until another query has produced a value it needs. The classic case is loading a user by email and then loading that user's projects by the user's id. TanStack Query v5 expresses this declaratively with the **`enabled`** option on the second `useQuery`: - the first query runs normally and exposes the user in `data`; - the second query derives `userId` from that `data` and passes `enabled: !!userId`; - when `userId` goes from `undefined` to a real value, `enabled` becomes `true` and the query fetches on its own. There is no `useEffect`, no manual trigger and no nested component that only mounts once the user exists. The dependency is stated as data: "this query is allowed to run when this value exists". ```tsx import { useQuery } from '@tanstack/react-query' function UserProjects({ email }: { email: string }) { const { data: user } = useQuery({ queryKey: ['user', email], queryFn: () => getUserByEmail(email), }) const userId = user?.id const projects = useQuery({ queryKey: ['projects', userId], queryFn: () => getProjectsByUser(userId!), enabled: !!userId, }) if (projects.isLoading) return <Spinner /> if (projects.isError) return <ErrorBox error={projects.error} /> if (projects.isPending) return <p>Waiting for the user record...</p> return <ProjectList projects={projects.data} /> } ``` ## What the disabled query reports over time The projects query moves through three combinations of its two state fields: | Moment | `status` | `fetchStatus` | `isPending` | `isLoading` | |---|---|---|---|---| | user not loaded yet (disabled) | `pending` | `idle` | true | false | | user arrived, request in flight | `pending` | `fetching` | true | true | | projects arrived | `success` | `idle` | false | false | The first row is the one that surprises people. `status: 'pending'` only means **"there is no data yet"**; it says nothing about whether a request is running. That is why a spinner keyed to `isPending` appears while the query is merely waiting - and if the user record never yields an id (a user with no account, a failed lookup), that spinner never goes away. `isLoading` is defined as `isPending && isFetching`, so it is true only in the second row. ## Why the id must be part of the query key `enabled` decides **when** the query may run; the **query key** decides **which cache entry** it reads and writes. If the key were just `['projects']` while the `queryFn` closed over `userId`: - every user's projects would share one cache entry, so switching users could briefly show the previous user's list; - a change of `userId` would not be seen as a new query, so nothing would fetch for the new user until some other trigger fired. Putting `userId` in the key, `['projects', userId]`, gives each user a separate entry and makes a new id start a new fetch automatically. The same rule applies to every variable the `queryFn` depends on. ## `enabled: false` versus `skipToken` In TypeScript, `getProjectsByUser(userId!)` needs a non-null assertion because the function is typed to run even though it only runs when `userId` exists. v5 exports **`skipToken`** as an alternative: pass it as the `queryFn` while the input is missing. ```tsx import { skipToken, useQuery } from '@tanstack/react-query' const projects = useQuery({ queryKey: ['projects', userId], queryFn: userId ? () => getProjectsByUser(userId) : skipToken, }) ``` | | `enabled: !!userId` | `queryFn: skipToken` | |---|---|---| | query runs automatically | only when the flag is true | only when a real function is passed | | type narrowing of the input | needs `!` or a guard | natural, inside the ternary | | `refetch()` while disabled | fetches anyway | fails with a missing-queryFn error | `enabled` also accepts a function of the query, `enabled: (query) => ...`, when the decision depends on the query's own state. ## Pitfalls worth naming in an interview 1. **Spinner on `isPending`** for a query that can be disabled - use `isLoading` for the spinner and give the disabled case its own branch, as the example does: once `isLoading` and `isError` are both false, a query that is still `isPending` is waiting, not loading. 2. **Id missing from the key** - stale data across users and no fetch on change. 3. **Calling `refetch()` to "force" a disabled query** - it works with `enabled: false` but bypasses the very condition you declared, and it fails outright with `skipToken`. 4. **Expecting parallelism** - a dependent query is serial by construction: the second request cannot start before the first response lands. That is the price of the dependency, not a bug in the library.

  • What happens to the projects query if the user query fails?
    `user` stays `undefined`, so `userId` is `undefined` and `enabled` stays false. The projects query remains `status: 'pending'`, `fetchStatus: 'idle'` until the user query eventually succeeds. The component must check the user query's `isError` first; otherwise a spinner tied to the projects query's `isPending` spins forever.
  • Can you pass a function to enabled instead of a boolean?
    Yes. In v5 `enabled` accepts a boolean or a function that receives the `Query` and returns a boolean, for example to decide from the query's own state. A boolean derived from props or another query's data covers the ordinary dependent-query case.

saying these in an interview costs you the question

  • Starts the second query from a useEffect that watches the first query's data.
  • Leaves userId out of the query key because the queryFn already closes over it.
  • Shows a spinner on isPending for a query that can sit disabled.
  • Believes a disabled query's status field reads idle, as a mutation's does.
  • Thinks skipToken and enabled: false both let refetch() run the query.
open as a page

In TanStack Query v5, after a mutation that creates a todo succeeds, how do you make the todo lists on screen show it?

level: juniorimportance: must knowfreq 70%

basics

~10 s

In the mutation's onSuccess, call queryClient.invalidateQueries({ queryKey: ['todos'] }). Every query whose key starts with 'todos' is marked stale; those currently rendered refetch in the background at once, the rest refetch when next mounted.

open as a page

In TanStack Query v5, what is the difference between the mutate and mutateAsync functions that useMutation returns?

level: juniorimportance: must knowfreq 68%

basics

~20 s

mutate starts the mutation and returns nothing; errors are caught internally and surface through callbacks and the hook's error state. mutateAsync returns a promise that resolves with the data or rejects with the error, so you must catch it yourself.

open as a page

In TanStack Query v5, how does useInfiniteQuery decide which page to fetch next, and how does it know there are no more pages?

level: juniorimportance: must knowfreq 62%

basics

~10 s

useInfiniteQuery fetches the first page with initialPageParam, then asks getNextPageParam(lastPage, allPages, lastPageParam, allPageParams) for each next param. Returning undefined or null sets hasNextPage to false. Pages accumulate in data.pages and data.pageParams.

open as a page

In TanStack Query v5, a todo list keyed ['todos'] has a queryFn reading a status filter; why does a filter change show the wrong list?

level: juniorimportance: must knowfreq 70%

basics

~20 s

The key is the cache identity, and ['todos'] never changes, so every filter shares one entry and a filter change starts no fetch. Put every variable the queryFn uses into the key, such as ['todos', 'list', { status }].

open as a page

In TanStack Query v5, what is the difference between staleTime and gcTime, and what are their default values?

level: middleimportance: must knowfreq 78%

basics

~20 s

staleTime (default 0) sets how long fetched data counts as fresh, skipping mount, focus and reconnect refetches; gcTime (default 5 minutes, formerly cacheTime) sets how long a query with no mounted observers stays in memory before deletion.

open as a page

In TanStack Query v5, what do a query's status and fetchStatus each describe, and which combinations of the two can occur?

level: middleimportance: must knowfreq 72%

basics

~20 s

status answers whether the query has data: pending, error or success. fetchStatus answers whether the queryFn is running: fetching, paused or idle. They vary independently, so success can pair with fetching and pending can pair with idle or paused.

open as a page

In TanStack Query v5, which cached queries does invalidateQueries({ queryKey: ['todos'] }) match, and how do exact: true and predicate narrow that?

level: middleimportance: must knowfreq 60%

basics

~20 s

Without options it matches every query whose key starts with 'todos', element by element, with objects matched by the properties you pass. exact: true matches only the identical key; predicate receives each Query and must also return true.

open as a page

In TanStack Query v5, in what order do useMutation's lifecycle callbacks run, and how do callbacks passed to mutate() differ from those passed to useMutation?

level: middleimportance: must knowfreq 60%

basics

~20 s

onMutate runs before the mutationFn, then onSuccess or onError, then onSettled; returned promises are awaited. Callbacks passed to mutate() run after those, only while the component is mounted, and only for the latest mutate call.

open as a page

With TanStack Query v5, a table keyed ['orders', page] flashes a loading state on every page change; how do you keep the previous page visible?

level: middleimportance: must knowfreq 60%

basics

~20 s

Each page number is a new query key, so its entry starts pending with no data. Setting placeholderData: keepPreviousData shows the previous page's data while the next one loads, with isPlaceholderData true until the real page arrives.

open as a page

Using TanStack Query v5, how would you optimistically toggle a todo's done flag so the change rolls back if the server answers with a 500?

level: seniorimportance: must knowfreq 58%

basics

~20 s

In onMutate, cancel in-flight fetches for the list, snapshot it with getQueryData, write the flipped flag with setQueryData and return the snapshot. In onError, write the snapshot back. In onSettled, invalidate the list so it matches the server.

open as a page

How do you poll an endpoint with TanStack Query v5's useQuery, and stop polling once a background job has finished?

level: juniorimportance: should knowfreq 45%

basics

~20 s

Set refetchInterval on useQuery: a number of milliseconds polls on a fixed timer, and a function receiving the query can return false once the job is complete, which stops polling. Hidden tabs skip polling unless refetchIntervalInBackground is true.

open as a page

In TanStack Query v5, how do placeholderData and initialData differ in what reaches the cache and how staleTime treats them?

level: middleimportance: should knowfreq 58%

basics

~10 s

initialData is written to the cache as real data and ages under staleTime as if just fetched; placeholderData is shown only to that observer while the query has no data, and is never cached.

open as a page

In TanStack Query v5, why does a query whose endpoint returns a 500 stay in its loading state for several seconds before an error appears?

level: middleimportance: should knowfreq 55%

basics

~20 s

On the client a failed query is retried 3 times by default, waiting about 1, 2 and 4 seconds between attempts. The error surfaces only after the fourth attempt fails; until then status stays pending and failureCount climbs.

open as a page

In TanStack Query v5, what does the select option of useQuery do, and why should the function you pass to it keep a stable reference?

level: middleimportance: should knowfreq 50%

basics

~20 s

select transforms or picks from the cached data for one observer; the cache keeps the raw queryFn result. It reruns only when the data or the select function's reference changes, so an inline function runs every render unless it is hoisted or memoized.

open as a page

In TanStack Query v5, what does the refetchType option of invalidateQueries control, and what happens to matched queries that no component is observing?

level: middleimportance: should knowfreq 42%

basics

~10 s

Every matched query is marked invalidated; refetchType only chooses which of them refetch now: 'active' (default), 'inactive', 'all' or 'none'. Unobserved matches are left stale and refetch when a component next mounts them.

open as a page

In TanStack Query v5, what does queryClient.setQueryData do to a cache entry, and why must its updater never mutate the old data in place?

level: middleimportance: should knowfreq 52%

basics

~20 s

setQueryData synchronously writes data for one exact key, creating the entry if needed, marks it successful and fresh, and notifies observers. Returning undefined leaves the cache untouched. Mutating old data in place keeps the same reference, so dependent UI can miss the change.

open as a page

In TanStack Query v5, how can a component show a pending write optimistically from useMutation's variables without touching the query cache?

level: middleimportance: should knowfreq 42%

basics

~20 s

While isPending is true, render mutation.variables as a provisional item next to the query's data. Keep the mutation pending until the refetch finishes by returning invalidateQueries from onSettled. On error, variables remain, so show a retry.

open as a page

When a stale TanStack Query v5 infinite query holding ten pages refetches, what requests does it send, and how does maxPages change that?

level: middleimportance: should knowfreq 42%

basics

~20 s

It refetches all ten pages one after another, starting from the first stored param and computing each next param from the freshly fetched page. maxPages caps how many pages are kept, so a refetch requests at most that many.

open as a page

How would you structure TanStack Query v5 keys for a todos feature with a key factory, and why nest them from general to specific?

level: middleimportance: should knowfreq 55%

basics

~20 s

Build every key from one factory, nested from general to specific: ['todos'], then ['todos', 'list'] with filters, then ['todos', 'detail', id]. Nesting lets one prefix address a whole group, and queryOptions() keeps each key with its queryFn and type.

open as a page

In TanStack Query v5, when do two query keys hit the same cache entry, and why might ['todo', 5] and ['todo', '5'] miss each other?

level: middleimportance: should knowfreq 50%

basics

~20 s

Keys are compared by a deterministic hash: JSON.stringify with plain-object properties sorted. Object property order does not matter, but array order and value types do, so the number 5 and the string '5' produce different cache entries.

open as a page

A React app using TanStack Query v5 sends a burst of requests each time a user switches back to its browser tab; why, and how would you fix it without hiding real updates?

level: seniorimportance: should knowfreq 62%

basics

~10 s

Default staleTime 0 makes all cached data stale and refetchOnWindowFocus defaults to true, so returning to the tab refetches every active query. Set realistic staleTime values rather than disabling focus refetching globally.

open as a page

In TanStack Query v5, after the browser fires an offline event, a newly mounted query neither fetches nor errors and its loading UI never resolves. What state is it in, and how does networkMode change that?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Under the default networkMode 'online', a query that should fetch after an offline event is not started: it is status pending with fetchStatus paused, so isLoading is false. It resumes on reconnect; render isPaused, or pick 'always' or 'offlineFirst'.

open as a page

In TanStack Query v5, a dialog saves a new todo, its Save button re-enables and the dialog closes, but the list only shows the todo a moment later. Why, and how do you fix it?

level: seniorimportance: should knowfreq 45%

basics

~20 s

The onSuccess callback calls invalidateQueries without returning its promise, so the mutation settles as soon as the POST succeeds while the refetch is still running. Return or await the invalidateQueries promise; the mutation then stays pending until the list has refetched.

open as a page

An infinite feed built with TanStack Query v5's useInfiniteQuery calls fetchNextPage from an IntersectionObserver sentinel, and the network tab shows repeated and cancelled page requests; why, and how do you fix it?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Repeated sentinel callbacks call fetchNextPage while a fetch is running, and its default cancelRefetch: true cancels that fetch and starts another for the same page. Guard the call with hasNextPage && !isFetching, or pass { cancelRefetch: false }.

open as a page

A TanStack Query v5 report keyed ['report', { from: new Date(), filters }] keeps sending requests and rarely shows data; what is wrong with that key?

level: seniorimportance: should knowfreq 32%

basics

~20 s

new Date() serializes to a new timestamp on every render, so each render builds a new key, misses the cache and sends a request. Compute the date once, in state or as a day string.

open as a page

In TanStack Query v5, what does structural sharing do to data after a background refetch, and when would you disable it?

level: middleimportance: nice to knowfreq 35%

basics

~20 s

Structural sharing deep-compares refetched data with the cached data and keeps old references for every unchanged part, so an identical response leaves data's reference unchanged. Disable it with structuralSharing: false for very large payloads or non-JSON data.

open as a page