Queries
The key is the question
A key is an array, and two keys equal by value are the same query — the same cache entry, the same request, the same answer.
key: ["user", id] // one user
key: ["posts", { page, tag }] // a filtered page of posts
An array rather than a string so it can be built from the parts a component already
has, and so a prefix of it can be invalidated without string surgery:
invalidate(["user"]) reaches ["user", 1] and ["user", 2] and leaves ["posts"]
alone.
Object keys are compared by value, and their properties are sorted before hashing —
{ page: 1, tag: "a" } and { tag: "a", page: 1 } are one query, so two components
writing the literal in a different order cannot split the cache in half.
Keys must be JSON-serializable. Not a rule invented here: the key is part of what
crosses the wire during hydration, which is the same constraint
@state already lives under. A function or a symbol in a key is
worse than an error — JSON.stringify drops it, so two different queries hash
identically and each renders the other's data. Development builds report that as
RMQ001; a Date or a class instance is reported too, because neither hashes
stably.
Changing the key asks a different question
The key usually depends on a prop, so a prop change is a key change:
private user = this.use(Query, (self: UserCard) => ({
key: ["user", self.props.id], // a literal; Query holds its identity
fetch: self.load, // a bound method, not a closure
}));
When id moves, the hook moves with it in the same render: it shows the new
key's state — pending, or whatever is cached for it — rather than the previous user's
name under the new user's heading for a frame. The request for the old key is
abandoned, and if you forwarded ctx.signal the browser stops it too.
Write the key array as a literal — that is the whole point of it. Query declares key
as a value (static StableProps), so the framework hands back one array identity for
as long as the parts are equal: nothing that reads the key sees a change, and the
comparison costs 31 ns. You do not wrap it in anything.
fetch is the one to watch, because a function cannot be compared that way. Pass a
bound method — fetch: self.load, reading this.props when it is called — rather than
an inline closure, which is a new prop on every render. Development builds report the
closure form as RMD022.
Freshness
Two options, and they answer different questions.
staleTime — how long the data counts as fresh. While fresh, mounting another
observer of the same key does not refetch; it renders what is there. Defaults to 0,
which means "stale the moment it arrives": right for data that changes under you, and
the reason navigating back to a page refreshes it.
gcTime — how long an entry with no observers is kept before it is dropped.
Defaults to five minutes. This is what makes going back instant: the data is still
there, shown immediately, and refreshed in the background if it is stale.
{ staleTime: 30_000, gcTime: 10 * 60_000 }
A query with data can be refetching at the same time, and those are separate facts:
isPending is "there is nothing to show yet", isFetching is "a request is in
flight". Rendering a spinner for the second one blanks a screen that has something on
it.
An equal answer is the same answer
When a fetch returns data equal to what is already cached, the cache keeps the object it
had — so nothing re-renders. And when the answer did change, every part that did not keeps
its identity, which is what lets list() re-render the rows that moved instead of
all of them.
Measured in jsdom against the render it prevents, on rows of six fields: 28 µs of comparison versus 5.4 ms of commit at ten rows, 811 µs versus 272 ms at a thousand. A polled query that returns the same page is the common case, not the exception, so this is on by default.
{ structuralSharing: false } // for a payload that is always different and big
Turn it off only for that: a response large enough for the walk to matter and different on
every fetch, where the comparison is pure cost. Arrays and plain objects are compared; a
Date, a Map or a class instance is compared by identity, because equality for those is
yours to define.
When it asks again
| Trigger | Default | Notes |
|---|---|---|
refetchOnMount | "stale" | "always" ignores freshness; false never refreshes on mount. Data from the server counts as fresh — see on the server |
refetchOnWindowFocus | true | When the tab becomes visible again, and only when STALE — an alt-tab inside staleTime costs nothing |
refetchOnReconnect | true | Same, when the browser comes back online |
refetchInterval | off | Polls every N ms, and ignores staleness — an interval is the freshness policy |
refetch() | — | Manual, ignores freshness, and joins a request already in flight rather than starting a second |
invalidate(key) | — | Marks stale and asks whoever is watching to refresh; the data stays on screen while it does |
The visibility trigger is named focus and watches document.visibilityState, which is the
question it is really asking. So a window that gains focus having been visible all along — a second
monitor, a split screen, DevTools — does not refetch, and a phone returning from the background does.
A focus event reports neither of those reliably.
A query with no data fetches under all of these: refetchOnMount decides whether
to REFRESH, and there is nothing to refresh yet.
Failure
retry defaults to 3 attempts after the first, with exponential backoff capped at
30 seconds (1s, 2s, 4s…) — a client that retries a struggling server immediately is
part of the problem. Both are options, and retry may be a predicate, which is what
an HTTP client wants:
{ retry: (failureCount, error) => (error as HttpError).status >= 500 }
A failed refetch keeps the data. status becomes "error" while data still
holds the last known value, because a network failure does not mean what is on screen
became wrong — only that it could not be confirmed. Render both, or ignore one:
if (this.user.isError && this.user.data) {
return <p>{this.user.data.name} <small>could not refresh</small></p>;
}
When the failure means the page cannot be shown
Sometimes an error is not something to render beside the content — it is the answer. Say so in the render:
render() {
if (this.user.isError) return <NotFound />;
if (this.user.isPending) return <p>Loading…</p>;
return <p>{this.user.data!.name}</p>;
}
There is no throwOnError, and that is deliberate rather than missing. Handing a failed
fetch to an error boundary replaces the whole subtree, which
means unmounting: @destroyed runs, cleanups run, local state goes, focus and scroll position
go — and a retry has to rebuild all of it. A failed request is not an unexpected situation.
The network fails routinely, which is why a failure is state here and the data you had is
kept. The two lines above unmount exactly what you chose to unmount, and nothing else.
What an app does lose without a boundary is the reminder to handle the error at all — so
development builds report a query that failed while the render never read isError, error,
status or result (RMQ002).
Starting with something already in hand
Two options, and the difference between them is the reason both exist.
initialData goes in the cache. It is the answer until something better arrives: every
observer of the key sees it, and staleness applies to it — so with the default staleTime: 0
it shows on the first render and is refreshed immediately.
{ initialData: cachedTodos, initialDataUpdatedAt: savedAt }
Pass initialDataUpdatedAt when the data is not new. Without it, seeded data looks freshly
fetched, so a one-minute staleTime would keep a value from localStorage for a minute
before checking.
placeholderData never touches the cache. It is a stand-in this component shows
instead of a spinner, and it is gone the moment the fetch lands:
{ placeholderData: emptyPage }
While it shows, status is "success" and data is the stand-in — which is the point, so
that the ordinary if (isPending) return <Spinner /> gives way to it. isPlaceholder is how
you tell: dim it, or hide the actions that would act on nothing. A failure is never hidden
by it; a placeholder covers "nothing yet", not "it went wrong".
Both take a function, and it is worth using for anything that is not free to build:
placeholderData: buildEmpty() runs the build every time the props callback runs — which for a
query is whenever the key moves — while placeholderData: buildEmpty is called once.
Holding a query back
A query that depends on something not there yet takes enabled:
private orders = this.use(Query, (self: Panel) => ({
key: ["orders", self.props.userId],
fetch: self.load,
enabled: self.props.userId !== undefined,
}));
Nothing is fetched and the status stays "pending" until it flips. That is better
than the alternative people reach for — a key with a hole in it,
["orders", undefined] — which fetches with nothing, caches the failure under a key
that will never be asked for again, and renders an error the user cannot act on.
Narrowing the result
The boolean getters are shortest, and they cannot narrow data: a getter tells the
compiler nothing about another getter, so data stays T | undefined however many
checks came before it. When you want it without a !, switch on result:
render() {
const user = this.user.result;
if (user.status === "pending") return <p>Loading…</p>;
if (user.status === "error") return <p>Failed.</p>;
return <p>{user.data.name}</p>; // data: User
}
Typing the fetcher
this.use infers a hook's props from the object you hand it. That works everywhere except one
place: an inline callback whose parameter you have not annotated. fetch: (ctx) => … asks
TypeScript to infer ctx from the same object it is currently inferring — ctx is built from the
key sitting next to it — so it gives up and hands you any:
// `ctx` is implicitly `any`, so `ctx.singal` (typo) passes.
this.use(Query, () => ({ key: ["user", id], fetch: (ctx) => loadUser(id, ctx.signal) }));
Pin the type arguments and everything else follows from them:
private key = ["user", this.props.id] as const;
private user = this.use(Query<User, typeof this.key>, () => ({
key: this.key,
fetch: (ctx) => loadUser(ctx.key[1], ctx.signal), // ctx.key[1] is the id, typed
}));
Pin both, not just the first. Query<User> alone fixes the any — but the key parameter then
falls back to its default, the wide QueryKey, so ctx.key[1] is unknown and the one thing you
reached into ctx for is gone.
A method needs no pin, because it carries its own annotation:
private user = this.use(Query, () => ({ key: ["user", this.props.id], fetch: this.loadUser }));
This is a TypeScript inference limit rather than a rule of this library, so it applies to any hook
whose props include a callback typed from a sibling property —
Form<typeof schema> is the same restriction for the same
reason.
Reaching the cache directly
For imperative work — prefetching in a parent, invalidating after something happened
outside a mutation — reach the client with QueryClientAccess:
class Page extends Component {
private queries = this.use(QueryClientAccess);
@mounted
warmUp() {
// Loads what this page needs in ONE place, so the children below find their
// data already cached instead of each fetching what the one above just learned.
return this.queries.client.prefetch(["todos"], loadTodos);
}
}
prefetch fetches only if what is cached is stale, and registers no observer. It is
the tool for flattening a waterfall — a server render
gives up after ten sequential rounds of fetch-triggers-fetch, and one
prefetch above the tree is how a page stays under it.
Also on the client: setData (write straight into the cache — a fetch in flight is
abandoned, because an explicit write is newer information than a request made before
it), peek, invalidate, remove for a logout, and cancel.
Next
- Mutations — writing, and rolling back.