skip to content

Query Lifecycle & Status

status and fetchStatus answer different questions — do I have data versus is a request in flight — and that split trips people up. Also covers dependent queries via enabled, retry behaviour, keepPreviousData, and select for derived data.

on this pageshow

explore

questions

5

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, 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, 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, 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