108: API Boundaries and TanStack Query v5 Queries
Learning objectives
By the end of this lesson, you should be able to:
- build a small API client boundary before adding cache behavior;
- classify transport errors and domain errors;
- configure
QueryClient; - use TanStack Query v5 object syntax only;
- design query keys;
- distinguish
statusfromfetchStatus; - reason about stale time and garbage-collection time;
- build dependent, selected, paginated, and prefetched queries;
- avoid duplicating query data into component/global state.
API client before cache client
TanStack Query manages server-state caching and synchronization; it does not make an HTTP request correct. Give the query library a request function with a clear contract first. That boundary is where response parsing, HTTP failure handling, credentials, and cancellation belong.
Start with a general request function:
export class ApiError
extends Error {
constructor(
message,
{
status,
body,
} = {},
) {
super(message);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
}
export async function request(
path,
{
signal,
...options
} = {},
) {
const response =
await fetch(path, {
credentials:
'same-origin',
signal,
...options,
headers: {
Accept:
'application/json',
...options.headers,
},
});
let body = null;
if (
response.status
!== 204
) {
const contentType =
response.headers.get(
'content-type',
);
if (
contentType?.includes(
'application/json',
)
) {
body =
await response.json();
}
}
if (!response.ok) {
throw new ApiError(
body?.error?.message
?? `HTTP ${
response.status
}`,
{
status:
response.status,
body,
},
);
}
return body;
}
There are two useful distinctions here. A transport-level failure is represented by a rejected request or a non-OK HTTP response. A domain-level failure may be encoded in the response body, such as a validation error or a forbidden operation. Keeping the response body and status on ApiError gives the UI and retry policy enough information to respond deliberately instead of treating every failure as an anonymous server error.
Once that boundary exists, add domain-specific functions. They describe the resource and its parameters without making components assemble URLs themselves:
export function getTasks({
status,
page,
signal,
}) {
const params =
new URLSearchParams({
status,
page: String(page),
});
return request(
`/api/tasks?${params}`,
{ signal },
);
}
The query layer can now focus on cache identity and lifecycle. It does not need to know how JSON is parsed or how a 422 differs from a 500.
This boundary also gives failures one predictable shape. Components can inspect an HTTP status when they need to choose a message, while the query library still receives the rejected promise it needs for its error state.
Keeping URL construction in the domain function makes request behavior easier to test. It also prevents individual components from quietly using different parameter names or forgetting a required value.
Query Client
The QueryClient owns the query cache and its defaults. Create it at application scope and provide it to the component tree:
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';
const queryClient =
new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 30_000,
},
},
});
createRoot(root).render(
<QueryClientProvider
client={queryClient}
>
<App />
</QueryClientProvider>,
);
Create the QueryClient once. Do not construct it during ordinary component rendering. A new client means a new cache, so rerenders or remounts could make existing data, observers, and invalidation behavior appear to vanish.
In a browser, that normally means one client for the application session. The provider can then give every participating component access to the same cache and observer graph.
On the server, lifetime has a different constraint. A server-rendered request should not accidentally reuse another request's client or its user-specific data.
TanStack Query v5 syntax
This course targets v5 and later.
The supported form used throughout this course is the single object passed to useQuery:
const query = useQuery({
queryKey: [
'tasks',
{
status,
page,
},
],
queryFn: ({ signal }) =>
getTasks({
status,
page,
signal,
}),
});
This course uses the single-object TanStack Query v5 API consistently. Do not introduce positional query overloads from older major versions. Consistency here matters because examples, reusable query options, and the v5 state model should all describe the same API.
Query keys are cache identity
The cache can only distinguish queries using the query key. A query key therefore needs every input that can change the returned data:
[
'tasks',
{
status,
page,
ownerId,
},
]
This is not enough:
['tasks']
if the query function secretly reads status, page, and ownerId. In that case, one cache entry could be reused for several different requests. The visible result would depend on which request happened to populate that entry first.
A useful hierarchy looks like this:
['tasks']
['tasks', { status: 'open' }]
['task', taskId]
['teams', teamId, 'tasks']
The exact shape is a design choice, but the team needs to use it consistently. A predictable hierarchy makes invalidation and debugging much easier.
Treat a key as a statement of identity, not merely as a label for a screen. The name tasks identifies a resource family; the filters and scope identify the particular observation within that family.
When a request starts returning surprising data, compare the request inputs with the key before inspecting rendering code. A missing input there is often the earliest and most useful diagnosis.
Status and fetch status
In v5, query state has two related but independent dimensions. This is where people commonly confuse “there is no data yet” with “a request is currently running.”
status describes the state of the data:
pendingerrorsuccess
fetchStatus describes what the query function is doing:
fetchingpausedidle
Because these dimensions answer different questions, a query can be:
status: success
fetchStatus: fetching
That combination means usable cached data is already available while a background refetch runs.
Do not replace useful content with a full-page spinner during every background refetch. Keep the existing content visible and provide a smaller refresh indication when that is useful.
Initial pending UI
An initial query needs explicit handling for pending, error, empty-success, and data-success states:
function TaskList({
status,
}) {
const query =
useQuery({
queryKey: [
'tasks',
{ status },
],
queryFn: ({
signal,
}) =>
getTasks({
status,
page: 1,
signal,
}),
});
if (query.isPending) {
return (
<p role="status">
Loading tasks…
</p>
);
}
if (query.isError) {
return (
<div role="alert">
<p>
Could not load
tasks.
</p>
<button
onClick={() =>
query.refetch()
}
>
Retry
</button>
</div>
);
}
if (
query.data.tasks
.length === 0
) {
return (
<p>
No tasks found.
</p>
);
}
return (
<ul>
{query.data.tasks.map(
(task) => (
<li key={task.id}>
{task.title}
</li>
),
)}
</ul>
);
}
The four states are:
- initial pending;
- error;
- success + empty;
- success + data.
An empty result is not an error, and a background fetch is not the same as initial pending. Keeping those distinctions in the UI gives users an honest view of what the application knows.
The error branch also exposes a recovery action without pretending that recovery is guaranteed. refetch() asks the query to try again; the result still has to pass through the same success or error handling.
staleTime
When a request seems to run “too often,” first ask whether the data should still be considered fresh. staleTime answers this question:
How long is this data considered fresh?
For example:
useQuery({
queryKey: ['countries'],
queryFn: getCountries,
staleTime:
24 * 60 * 60 * 1000,
});
Reference data may remain fresh for a long time. A rapidly changing queue may need a much shorter stale time. Choose the value from the domain's freshness requirements, not as a random way to suppress requests.
gcTime
gcTime controls how long unused query data remains in the cache before garbage collection. It answers a different question from staleTime.
Freshness asks whether data should be refetched. Retention asks whether unused data should still be available if a component subscribes again. Do not confuse:
staleTime
with:
gcTime
Data can be stale and still retained in the cache.
For example, returning to a page may show stale retained data immediately and then refetch it. That is different from waiting for a cache entry that has already been collected, and the UI can make the two experiences feel different without inventing data.
Query cancellation
TanStack Query supplies an AbortSignal to the query function:
useQuery({
queryKey: [
'task',
taskId,
],
queryFn: ({ signal }) =>
request(
`/api/tasks/${taskId}`,
{ signal },
),
});
Pass that signal through to fetch rather than dropping it at the API boundary. When the query becomes irrelevant, supported work can then be cancelled instead of continuing to consume resources for a result nobody is waiting for.
Dependent queries
Some requests genuinely need data from an earlier request. For example, projects cannot be requested until the current user's ID is known:
const userQuery =
useQuery({
queryKey: ['me'],
queryFn: getMe,
});
const projectsQuery =
useQuery({
queryKey: [
'projects',
userQuery.data?.id,
],
queryFn: ({ signal }) =>
getProjects({
userId:
userQuery.data.id,
signal,
}),
enabled:
Boolean(
userQuery.data?.id,
),
});
Use enabled when a query truly cannot run until required input exists. The key includes the ID so each user's projects have distinct cache identity.
Do not create a dependent chain merely for organizational neatness. If two requests do not depend on one another, let them run in parallel.
The enabled condition should describe a real prerequisite, not a workaround for an event flow that belongs elsewhere. If the prerequisite disappears, the key and enabled state should also make it clear why the dependent query is no longer active.
Select data
select is useful when a consumer needs a projection rather than the entire server response:
const openCountQuery =
useQuery({
queryKey: ['tasks'],
queryFn: getAllTasks,
select: (data) =>
data.tasks.filter(
(task) =>
!task.completed,
).length,
});
select transforms the result observed by this consumer; it does not mutate the value held in the cache. Use it for projections such as counts, filtered views, or a view-model value, while keeping the source response available to other consumers.
Pagination
Pagination is a cache-identity problem as well as a rendering problem. Put the page, along with any other filter, in the key:
const query =
useQuery({
queryKey: [
'tasks',
{
status,
page,
},
],
queryFn: ({ signal }) =>
getTasks({
status,
page,
signal,
}),
placeholderData:
(previousData) =>
previousData,
});
placeholderData allows the old page to remain visible while the new page is fetched. The key still changes, so the pages remain separate cache entries.
If the displayed data is from the previous page, indicate that the page change is still pending or that the data may be stale. Do not make the interface imply that the new page has arrived when it has not.
This is a useful example of separating continuity from certainty. Keeping the previous page avoids an unnecessary blank screen, while a pending indicator tells the user that the visible result is not yet the requested page.
Prefetch
When navigation strongly predicts the next resource, prefetch it:
await queryClient.prefetchQuery({
queryKey: [
'task',
taskId,
],
queryFn: ({ signal }) =>
getTask({
taskId,
signal,
}),
});
Prefetching can make the eventual route feel immediate, but it is still real network work. Do not prefetch the entire application by default, particularly on constrained networks.
Query ownership
The query cache should own server data. Avoid copying that data into a second state store:
const query =
useQuery(...);
const [tasks, setTasks] =
useState([]);
useEffect(() => {
setTasks(query.data);
}, [query.data]);
This creates two owners for the same value. They can diverge, and the effect adds another synchronization step that the UI now has to reason about.
Render from query.data. Create explicit local draft state when a user is editing a value and the draft intentionally differs from the last server response; that is a different ownership decision.
The same rule applies when several components need the resource. They should observe one cache entry rather than each create a private copy and then negotiate synchronization between those copies.
Devtools
TanStack Query Devtools make the cache's behavior visible while you learn and debug. Use them to inspect:
- cache keys;
- observers;
- stale/fresh state;
- inactive queries;
- refetches.
The goal is to understand why a request occurred. Do not keep changing options until requests happen to disappear; that hides the architecture problem instead of diagnosing it.
For a concrete investigation, start with the key and the query's active observers. Then check whether the data is stale, whether a focus or reconnect event occurred, and whether a component mounted a different key. Those observations are more useful than guessing at a global setting.
Common mistakes
- old v4 positional syntax;
- missing parameters from query keys;
- using
isFetchingas initial-loading state; - disabling all refetches to hide architecture issues;
- mirroring query data into Redux or local state;
- not passing
signal; - retrying 401/403 validation failures blindly;
- treating every error as a generic 500.
Exercises
- Create a QueryClient and provider.
- Build a v5 task query using object syntax.
- Add status and page to the key.
- Add
selectfor open-count projection. - Add cancellation through
signal. - Add prefetch for a task details route.
- Explain
status,fetchStatus,staleTime, andgcTime.
Exit questions
- What makes a good query key?
- What is the difference between stale time and GC time?
- How can a query be successful and fetching at the same time?
- Why should query data not be mirrored into component state?
- How does cancellation flow from TanStack Query to fetch?
- Why does this course use object syntax only?
Official references
- https://tanstack.com/query/latest/docs/framework/react
- https://tanstack.com/query/latest/docs/framework/react/reference/useQuery
- https://tanstack.com/query/latest/docs/framework/react/guides/query-keys
- https://tanstack.com/query/latest/docs/framework/react/guides/paginated-queries
- https://tanstack.com/query/latest/docs/framework/react/guides/query-cancellation
Deep dive: server state has time semantics
The reason query state needs more vocabulary becomes clearer when you compare it with local state. Local state such as:
const [open, setOpen] = useState(false);
is authoritative inside the component. The component owns the value and decides when to change it.
Server state is different: it is a cached observation of remote truth. A cached task list can become out of date because another user, process, or device changed the server. That means it raises questions local state does not answer by itself:
When was it fetched?
Is it still fresh?
Can another user change it?
Should it refetch on focus?
What if offline?
Which mutation invalidates it?
Can multiple components share it?
TanStack Query exists to model those questions rather than forcing each component to build its own synchronization system.
Query lifecycle vocabulary
A query can have cached data and still be fetching. For example:
status = success
fetchStatus = fetching
The meaning is:
usable data exists
background refetch is happening
That state can have a small refresh indicator without discarding the list:
{query.isFetching && !query.isPending && (
<span role="status">Refreshing…</span>
)}
Do not replace the list with its initial skeleton just because a background refresh occurs. The user already has useful content, and hiding it makes a normal synchronization step look like a blank loading state.
isPending, isLoading, and disabled/dependent queries
In TanStack Query v5, use the actual state model instead of memorizing one boolean and applying it everywhere.
isPending is based on status. isLoading is useful for a first-fetch situation and is derived from pending plus fetching behavior. They are related, but they are not interchangeable with every possible query state.
A disabled query can be pending without currently fetching. This is expected for a dependent query whose required input has not arrived yet.
When the UI behavior is surprising, inspect both dimensions directly:
query.status
query.fetchStatus
That usually tells you whether the issue is missing data, an active request, a paused request, or a query that is not enabled.
Query defaults
By default, cached query data becomes stale quickly and may refetch on common triggers such as mounting, focus, or reconnect. Developers often interpret this as “React Query fetches too much.” The better question is whether the default freshness policy matches this resource.
Configure freshness from domain semantics. For reference countries:
staleTime: 24 * 60 * 60 * 1000
For a live order queue:
staleTime: 5_000
or use a polling or realtime strategy when that better describes the product requirement.
Do not set staleTime: Infinity globally just to stop requests. That trades a visible request problem for silently outdated data across unrelated resources.
gcTime
Inactive cache data can remain available for a period after no component observes it. That supports this common navigation flow:
navigate away
return soon
show cached data immediately
maybe refetch based on stale state
Freshness and retention remain independent. A query can be stale but still cached, or it can be removed after becoming inactive even though it had not yet become stale.
Query key design at scale
As an application grows, construct keys in one consistent place. A factory can encode the hierarchy and reduce accidental variation:
export const taskKeys = {
all: ['tasks'],
lists() {
return [...this.all, 'list'];
},
list(filters) {
return [...this.lists(), filters];
},
details() {
return [...this.all, 'detail'];
},
detail(id) {
return [...this.details(), id];
},
};
Then a component uses the same definition rather than rebuilding the array by hand:
useQuery({
queryKey: taskKeys.list({ status, page }),
...
});
The benefits are:
- predictable invalidation;
- less typo drift;
- clear hierarchy.
There is one implementation detail worth keeping in mind: methods that use this can behave unexpectedly if you destructure them. Plain functions or objects that do not depend on this may be simpler when the factory is shared broadly.
The factory does not remove the need to review key contents. It only centralizes the shape. Every filter that can alter the server result still belongs in the filters value passed to the factory.
Stable key serialization
TanStack Query hashes query keys deterministically. Put serializable values in keys so that the identity represents the request inputs clearly.
Avoid class instances and functions as hidden cache identity. They make the key harder to inspect and can produce identity behavior that does not communicate the actual resource parameters.
If a date is part of the result identity, make the serialized value explicit:
date.toISOString()
The key should describe the value that changes the result, not an incidental object reference.
Query function errors
fetch does not reject automatically for HTTP 404 or 500 responses. This wrapper is therefore unsafe:
queryFn: () => fetch('/api/tasks').then(r => r.json())
A 500 response may resolve normally and be treated as successful query data. The API boundary must check the response and throw:
if (!response.ok) {
throw new ApiError(...);
}
Query functions need to throw or reject on failure. Only then can query error state, retry policy, and error UI represent the HTTP outcome correctly.
This is why the API boundary comes before the cache boundary. A cache can retain and retry a rejected operation, but it cannot infer that a resolved 500 response should have been considered a failure after the response body was already accepted as data.
Retry policy
Retrying is not automatically helpful for every error. A transient network problem or a 5xx response may recover; an authentication, authorization, missing-resource, or validation failure will not become valid merely because the client tried again.
For example:
retry(failureCount, error) {
if (error.status === 401) return false;
if (error.status === 403) return false;
if (error.status === 404) return false;
if (error.status === 422) return false;
return failureCount < 2;
}
The policy is domain-specific. A forbidden request will not become allowed after three attempts, while a temporary server or network problem may deserve another try.
enabled and lazy thinking
This is a good use of enabled:
enabled: Boolean(userId)
The query cannot be identified or executed correctly without userId.
Avoid using enabled: false as the default architecture for every query that is triggered by a button. Queries are declarative:
when key/input exists, this cache entry represents this server resource
For a click-triggered download or a mutation-like operation, an event handler or mutation may better express the behavior. Disabling every query turns a cache model into an imperative fetch collection and makes lifecycle behavior harder to reason about.
select
Suppose the API returns:
{
"tasks": [...],
"meta": {...}
}
If one consumer needs only completed tasks, project that observer's result:
useQuery({
...taskListQueryOptions(filters),
select(data) {
return data.tasks.filter((task) => task.completed);
},
});
This derives observer output without creating a second cache. Do not mutate data; other observers still rely on the original cached response.
Placeholder versus initial data
placeholderData can temporarily display substitute or previous data while the real query resolves. It is presentation-time data for the observer.
initialData seeds the cache as actual data and therefore participates in freshness semantics. That distinction matters: initial data claims the cache has a real starting value, while placeholder data says the displayed value is temporary.
Use each intentionally. Do not create fake placeholder records that users could mistake for authoritative server data.
The distinction is especially important when a placeholder contains fields that look complete. A temporary visual state should be recognizable as temporary, while initialData should come from a source the application is willing to treat as real cached data.
Prefetch and ensure data
A hover is one possible prediction of navigation:
queryClient.prefetchQuery({
queryKey: taskKeys.detail(id),
queryFn: ({ signal }) => getTask({ id, signal }),
});
Later navigation can reuse the cache. Router integration can call ensureQueryData or prefetch-style APIs depending on the application architecture.
Prefetch only where the prediction and cost justify it. Avoid fetching large datasets on every hover when users may be on constrained networks or may never follow the link.
Query options reuse
TanStack Query v5 supports reusable options patterns:
function taskDetailOptions(id) {
return queryOptions({
queryKey: taskKeys.detail(id),
queryFn: ({ signal }) => getTask({ id, signal }),
staleTime: 60_000,
});
}
This lets the same query identity and function be reused by:
- component query;
- prefetch;
- router loader.
Keep the v5 object API throughout. Reuse should reduce drift, not conceal which inputs define the cache entry.
Structural sharing
TanStack Query tries to preserve references for unchanged JSON-compatible data. Preserving those references can reduce downstream rerenders when a refetch returns values that did not actually change.
Do not deep-clone every response before storing it:
JSON.parse(JSON.stringify(data))
That destroys reference preservation and can also destroy types. Let the cache compare and retain compatible structure unless there is a specific, understood reason to transform the response.
Offline/network mode
Applications that promise offline behavior need more than a cache that happens to remain visible. Query and mutation network modes, persistence, and recovery rules become explicit design topics.
Do not claim “offline support” merely because cached data is displayed. True offline architecture needs:
- persisted cache if reload should work;
- queued writes/conflict rules;
- reconciliation;
- UX for stale data.
The server, local persistence, and user interface each need a defined role when connectivity disappears and later returns.
Offline behavior also raises conflict questions. A cached read can be displayed safely with a stale label, but an offline write needs a policy for ordering, failure, and reconciliation rather than only a longer cache lifetime.
Failure clinic
Query data copied to Redux
Duplicate ownership. The cache and Redux can disagree, so the application now needs synchronization logic for data TanStack Query already owns.
Query key omits filter
The wrong cached result can be reused because two different requests appear to be the same query.
404 treated as success
The fetch wrapper did not throw. Query state therefore saw a resolved promise instead of an error.
Every query uses same staleTime
Domain freshness was ignored. Reference data and rapidly changing operational data do not necessarily deserve the same policy.
Refetch on every render
This is usually caused by unstable architecture or a misunderstanding of query identity and defaults. Use Devtools to inspect the key, observers, and refetch trigger before changing options.
Deep-dive exercises
- Build a query-key factory.
- Add retry policy by HTTP class.
- Compare stale and inactive cache behavior.
- Build dependent user→projects query.
- Use
selectfor a derived projection. - Reuse query options in both loader prefetch and component.
- Use Devtools to explain every request instead of guessing.
Mastery check
Explain:
- server-state time semantics;
- status versus fetchStatus;
- staleTime versus gcTime;
- query-key hierarchy;
- why fetch HTTP errors must be thrown;
- how prefetching and structural sharing affect UX/performance.
Production case study: dashboard query key architecture
In a dashboard, orders may differ by status, page, branch, detail view, and timeline. Encode those distinctions in a query-key factory rather than relying on callers to remember the structure:
export const orderKeys = {
all: ['orders'],
lists() {
return ['orders', 'list'];
},
list(filters) {
return ['orders', 'list', filters];
},
details() {
return ['orders', 'detail'];
},
detail(orderId) {
return ['orders', 'detail', orderId];
},
timeline(orderId) {
return ['orders', 'detail', orderId, 'timeline'];
},
};
Use the factory at the query boundary:
useQuery({
queryKey: orderKeys.list({
status,
page,
branchId,
}),
queryFn: ({ signal }) =>
getOrders({
status,
page,
branchId,
signal,
}),
});
After a mutation, invalidating the list hierarchy can refresh affected list queries:
queryClient.invalidateQueries({
queryKey: orderKeys.lists(),
});
That can happen while updating a detail entry directly when the mutation response contains enough authoritative detail data.
Query key design test
For any two requests, ask:
Could they ever return different data?
If the answer is yes, their keys must differ. Typical differences include:
branch A versus branch B
page 1 versus page 2
open versus completed
user t1 versus user t2
Leaving tenant, branch, or user scope out of a key can cause serious correctness problems and, in a multi-tenant client, even privacy problems if a cached view is incorrectly reused. The API must still enforce tenant security on the server, but cache identity should mirror result identity correctly as well.
The design test is simple enough to use in code review: list the inputs that can change the response, then locate each one in the key. If an input is absent, the query is underspecified even if the current screen appears to work.
Additional depth: Suspense queries, error boundaries, and cache ownership
TanStack Query v5 also provides query APIs designed for Suspense. Conceptually:
const { data } = useSuspenseQuery({
queryKey: ['task', taskId],
queryFn: ({ signal }) => getTask({ taskId, signal }),
});
During a successful render, the component can assume data is available. Pending behavior moves to Suspense, and errors can integrate with Error Boundaries.
That changes where the branching happens:
normal useQuery
→ component handles pending/error/success
versus:
useSuspenseQuery
→ boundary handles pending/error
→ component renders successful data
Neither approach is universally better. Boundary design, router behavior, and framework integration determine which model fits the application.
Error reset
When a query error is thrown to an Error Boundary, retry and reset need coordination between:
Error Boundary
Query error state
TanStack Query provides reset-boundary utilities and patterns for that coordination. Do not build a Retry button that resets only the React boundary while the query remains errored and no query retry has occurred.
Hydration
Server-rendered applications may follow a flow like this:
create request-scoped QueryClient
prefetch
dehydrate
send safe state
hydrate client
After hydration, useQuery can reuse the server-fetched cache. Be careful not to serialize secrets or cache state belonging to another user. Request scoping and the data selected for dehydration are part of the security boundary.
Hydration is therefore a transfer of selected server state, not permission to copy every server-side object into browser memory. Decide which data is safe and useful to send before enabling the flow.
Query client lifetime
For a browser application, the usual model is:
one QueryClient per application session
For server rendering, use:
request-scoped server QueryClient
Do not create a QueryClient inside ordinary component render:
function App() {
const queryClient = new QueryClient();
Rerenders or remounts can then destroy cache identity. On a server, sharing a client across requests can additionally risk cross-user cache leakage, which is why request scope matters there.
Cache is not a database
The query cache is disposable. The server remains the source of truth.
Do not rely on the browser query cache as durable persistence for unsaved critical user work unless you intentionally add persistence and draft architecture. Cached server observations are useful for responsiveness, but they are not a guaranteed record of work that has not been saved.
