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?
answer
- a boolean option on the second query
- the id belongs in the key
- pending status, but nothing running
- a typed alternative to disabling
basics
~20 sGive 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 sThe 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
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.
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.
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.
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.