In Apollo Client 4, what does useQuery return for an issue list while loading, on error and on success, and how do you render each?
answer
- four fields, not three
- the states can overlap
- loading can sit beside cached rows
- a failed refetch keeps the old rows
- dataState: empty, partial, streaming, complete
basics
~20 sApollo Client 4's useQuery returns loading, error, data and dataState, which can overlap: cached rows arrive with loading true, and a failed refetch keeps rows beside error. Render data when present, else the error or a spinner.
solid answer
~40 s`useQuery` from `@apollo/client/react` returns `data`, `loading`, `error`, `networkStatus` and, new in 4.x, `dataState` (`empty`, `partial`, `streaming` or `complete`). On a first fetch with nothing cached you get `loading: true` and `data: undefined`. On success `loading` is false and `dataState` is `complete`. The states overlap, though. With `cache-and-network`, cached rows arrive while `loading` is still true. Because `notifyOnNetworkStatusChange` now defaults to `true`, a `refetch` or a poll flips `loading` back to true while `data` still holds the old rows. A failed refetch for the same variables keeps those rows and sets `error` beside them. So render rows whenever `data` exists (`dataState === "complete"` also narrows the TypeScript type). Show a full error only when there is no data, and a skeleton only when there is neither.
code
tsx · 29 linesimport { gql, type TypedDocumentNode } from "@apollo/client";
import { useQuery } from "@apollo/client/react";
type Issue = { __typename: "Issue"; id: string; title: string; status: "OPEN" | "CLOSED" };
const ISSUES: TypedDocumentNode<{ issues: Issue[] }> = gql`
query Issues {
issues { id title status }
}
`;
export function IssueList() {
const { data, dataState, loading, error } = useQuery(ISSUES);
if (dataState === "complete") {
return (
<section aria-busy={loading}>
{error && <p role="alert">Refresh failed: {error.message}</p>}
<ul>
{data.issues.map((issue) => (
<li key={issue.id}>{issue.title} ({issue.status})</li>
))}
</ul>
</section>
);
}
if (error) return <p role="alert">Could not load issues: {error.message}</p>;
return <p>Loading issues...</p>;
}go deeper
Recall the fields useQuery returns and that Apollo Client 4 hooks come from @apollo/client/react. Show you render rows from data rather than assuming loading and data never coexist.
Explain why loading can be true beside cached data: cache-and-network, plus refetch and polling now that notifyOnNetworkStatusChange defaults to true. Use dataState and networkStatus to tell those cases apart.
Show a render order that never blanks a list the user was reading: stale rows with an inline banner on a failed refetch, and a full error state only without data. Treat reverting notifyOnNetworkStatusChange as a migration lever, not a fix.
Frame loading and error states as a product rule: which screens may show stale rows during a refresh, and whether a shared list component should own that rule instead of every team re-deciding it.
## What useQuery hands back In Apollo Client 4 the React hooks live in the **`@apollo/client/react`** entry point; the core `@apollo/client` package no longer exports React APIs. You call `useQuery(ISSUES)` with a query document, and the hook returns one result object that is re-created whenever the query's state changes. The fields you render from are: | Field | What it tells you | Typical values | |---|---|---| | `data` | The query result, if Apollo has one | `undefined` until something arrives | | `dataState` | How complete `data` is | `empty`, `partial`, `streaming`, `complete` | | `loading` | Whether a request for this query is in flight | `true` / `false` | | `error` | One error object for the latest execution | `undefined` when it succeeded | | `networkStatus` | Why the query is busy | a `NetworkStatus` number | `dataState` is new in 4.x. `partial` only appears when you opt into `returnPartialData`, and `streaming` only with `@defer`, so for an ordinary issue list you will mostly see `empty` and `complete`. ## The plain lifecycle For an issue list mounted with the default `cache-first` policy and an empty cache, the hook moves through three states: 1. **First render:** `loading: true`, `data: undefined`, `dataState: "empty"`, `networkStatus` is `NetworkStatus.loading`. 2. **Success:** `loading: false`, `data` holds `{ issues: [...] }`, `dataState: "complete"`, `networkStatus` is `ready`. 3. **Failure on that first request:** `loading: false`, `error` is set, `data` stays `undefined`, `networkStatus` is `error`. If the whole list is already cached, `cache-first` skips step 1 and the first render is already complete. ## Why the states are not mutually exclusive The classic tutorial shape, `if (loading) return <Spinner />; if (error) return <Error />; return <List />`, assumes that only one flag is ever true. In Apollo Client 4 that assumption breaks in several everyday cases: - **`cache-and-network`** renders the cached rows immediately and still sends a request, so `data` is complete while `loading` is `true`. - **Refetching and polling** re-render with `loading: true` because **`notifyOnNetworkStatusChange` now defaults to `true`**. The rows the user was reading are still in `data`, and `networkStatus` says `refetch` or `poll`. - **A failed refetch** for the same query and variables keeps the previous result and adds `error` next to it, so `data` and `error` are both set. - **A skipped query** (passing `skipToken`) returns `loading: false` with `data: undefined` and `dataState: "empty"`, so "not loading" does not mean "has data". - **The default `errorPolicy` of `none`** discards partial data from a response that carries GraphQL errors, so a first load that fails has no rows at all. A component that returns a spinner whenever `loading` is true therefore blanks the list on every refresh. One that returns an error screen whenever `error` is set throws away rows that were still perfectly readable. ## A render order that survives all of them Check for data first, then decide what to overlay: 1. If `dataState === "complete"`, render the rows. Add a small busy indicator when `loading` is true and an inline banner when `error` is set. 2. Otherwise, if `error` is set, render a full error state, because there is nothing else to show. 3. Otherwise render the skeleton or spinner. The `dataState` check doubles as TypeScript narrowing: inside that branch `data` is the complete result type, not `undefined`. ## networkStatus when you need more detail `networkStatus` is a `NetworkStatus` enum imported from `@apollo/client`. Its values separate situations that `loading` lumps together: - `loading` (1) for a first execution and `setVariables` (2) for a change of variables. - `fetchMore` (3), `refetch` (4) and `poll` (6) for the different kinds of refresh. - `ready` (7) when idle, `error` (8) after a failure, and `streaming` (9) while a deferred response is still arriving. A common use is showing a thin progress bar only for `NetworkStatus.refetch` while keeping the list interactive. ## Mistakes interviewers listen for - Treating `loading === false` as a promise that `data` is defined, and crashing on `data.issues` when the query was skipped or failed. - Importing `useQuery` from `@apollo/client`, which is the Apollo Client 3 path. - Reaching for an `onCompleted` callback on `useQuery` to react to new data: Apollo Client 4 removed `onCompleted` and `onError` from `useQuery` and `useLazyQuery`, so you derive UI from `data` and `error` during render. - Turning `notifyOnNetworkStatusChange` off globally just to hide refresh spinners. It works, since it restores the 3.x behaviour, but it also hides refresh progress that the render order above can show properly.
- What does networkStatus tell you that loading does not?`loading` is a single boolean, while `networkStatus` says why the query is busy: `loading` for a first run, `setVariables` after new variables, `fetchMore`, `refetch`, `poll`, then `ready`, `error` or `streaming`. Comparing it with `NetworkStatus.refetch`, imported from `@apollo/client`, lets you show a small refresh indicator while keeping the issue list on screen.
- How does the result of useMutation differ from useQuery's?`useMutation` returns a tuple: a `mutate` function and a result with `data`, `loading`, `error`, `called`, `client` and `reset`. Nothing runs until you call `mutate`, so `called` and `loading` start false. `mutate` returns a promise that rejects on failure under the default `errorPolicy`, and `reset` clears the hook's state without touching the cache.
- What does useQuery return when the query is skipped with skipToken?It returns `loading: false`, `data: undefined`, `dataState: "empty"` and `networkStatus` `ready`, because the query is parked in `standby` and never sent. A component that only checks `loading` before reading `data.issue` crashes in that state, so check `data` or `dataState` instead.
saying these in an interview costs you the question
- When loading is false, data is always defined.
- loading, error and data are mutually exclusive, so check loading first and return a spinner.
- Whenever error is set, data is undefined and the list must be replaced by an error screen.
- In Apollo Client 4 useQuery is still imported from @apollo/client.
- useQuery's onCompleted callback is the place to react to fresh data in Apollo Client 4.