108: API Boundaries and TanStack Query v5 Queries
Learning objectives
You will learn 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 does not replace HTTP correctness.
First build a 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;
}
Then domain functions:
export function getTasks({
status,
page,
signal,
}) {
const params =
new URLSearchParams({
status,
page: String(page),
});
return request(
`/api/tasks?${params}`,
{ signal },
);
}
Query Client
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 create it during ordinary component rendering.
TanStack Query v5 syntax
This course uses v5 and above only.
Correct:
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.
Query keys are cache identity
A query key should contain every input that changes the result.
Correct:
[
'tasks',
{
status,
page,
ownerId,
},
]
Wrong:
['tasks']
if the query function secretly reads status, page, and ownerId.
A useful hierarchy:
['tasks'] ['tasks', { status: 'open' }] ['task', taskId] ['teams', teamId, 'tasks']
Be consistent.
Status and fetch status
In v5, query state has two important dimensions.
status describes data state:
pendingerrorsuccess
fetchStatus describes query-function activity:
fetchingpausedidle
A query can be:
status: success fetchStatus: fetching
when it has usable cached data while a background refetch runs.
Do not replace useful content with a full-page spinner during every background refetch.
Initial pending UI
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>
);
}
Notice four states:
- initial pending;
- error;
- success + empty;
- success + data.
staleTime
staleTime answers:
How long is this data considered fresh?
useQuery({
queryKey: ['countries'],
queryFn: getCountries,
staleTime:
24 * 60 * 60 * 1000,
});
Reference data may stay fresh for a long time.
A rapidly changing queue may need a shorter stale time.
Do not set stale time randomly to stop requests you do not understand.
gcTime
gcTime controls how long unused query data remains in cache before garbage collection.
Freshness and cache retention are different questions.
Do not confuse:
staleTime
with:
gcTime
Query cancellation
TanStack Query passes an AbortSignal:
useQuery({
queryKey: [
'task',
taskId,
],
queryFn: ({ signal }) =>
request(
`/api/tasks/${taskId}`,
{ signal },
),
});
Pass it through to fetch.
This lets irrelevant work be cancelled where supported.
Dependent queries
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.
Do not create a dependent chain if requests could run in parallel.
Select data
const openCountQuery =
useQuery({
queryKey: ['tasks'],
queryFn: getAllTasks,
select: (data) =>
data.tasks.filter(
(task) =>
!task.completed,
).length,
});
select transforms the observed result; it does not mutate the cache value.
Use it for consumers that need a projection.
Pagination
Put page in the key:
const query =
useQuery({
queryKey: [
'tasks',
{
status,
page,
},
],
queryFn: ({ signal }) =>
getTasks({
status,
page,
signal,
}),
placeholderData:
(previousData) =>
previousData,
});
The old page can remain visible while the new page is fetched.
Indicate stale/pending page change honestly.
Prefetch
await queryClient.prefetchQuery({
queryKey: [
'task',
taskId,
],
queryFn: ({ signal }) =>
getTask({
taskId,
signal,
}),
});
Use prefetch when user behavior strongly predicts the next resource.
Do not prefetch the entire application by default.
Query ownership
Do not do this:
const query =
useQuery(...);
const [tasks, setTasks] =
useState([]);
useEffect(() => {
setTasks(query.data);
}, [query.data]);
Now the query cache and component state both claim to own the same server data.
Render from query.data, or create explicit draft state only when editing.
Devtools
TanStack Query Devtools are useful for learning:
- cache keys;
- observers;
- stale/fresh state;
- inactive queries;
- refetches.
Use them to understand behavior rather than changing options until requests disappear.
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
Local state such as:
const [open, setOpen] = useState(false);
is authoritative inside the component.
Server state is a cached observation of remote truth.
A cached task list has questions:
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.
Query lifecycle vocabulary
A query can have cached data and still be fetching.
Example:
status = success fetchStatus = fetching
Meaning:
usable data exists background refetch is happening
UI:
{query.isFetching && !query.isPending && (
<span role="status">Refreshing…</span>
)}
Do not replace the list with initial skeleton just because background refresh occurs.
isPending, isLoading, and disabled/dependent queries
In TanStack Query v5, learn the actual state model rather than memorizing one boolean.
isPending is based on status.
isLoading is useful for first fetch situations and is derived from pending + fetching behavior.
A disabled query can be pending without currently fetching.
Inspect:
query.status
query.fetchStatus
when confused.
Query defaults
By default, cached query data becomes stale quickly and may refetch under common triggers such as mounting/focus/reconnect.
Developers often misinterpret this as "React Query fetches too much."
Instead configure freshness based on domain semantics.
Reference countries:
staleTime: 24 * 60 * 60 * 1000
Live order queue:
staleTime: 5_000
or polling/realtime strategy.
Do not set staleTime: Infinity globally just to stop requests.
gcTime
Inactive cache can remain for a period.
This supports:
navigate away return soon show cached data immediately maybe refetch based on stale state
Freshness and retention are independent.
A query can be stale but still cached.
Query key design at scale
Use a consistent factory:
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:
useQuery({
queryKey: taskKeys.list({ status, page }),
...
});
Benefits:
- predictable invalidation;
- less typo drift;
- clear hierarchy.
Be cautious with methods using this if you destructure them; plain functions/objects without this can be simpler.
Stable key serialization
TanStack Query hashes query keys deterministically.
Put serializable values in keys.
Avoid class instances/functions as hidden cache identity.
If Date matters:
date.toISOString()
can make intent explicit.
Query function errors
Fetch does not reject for 404/500 automatically.
Wrong:
queryFn: () => fetch('/api/tasks').then(r => r.json())
A 500 response may resolve and be treated as success.
Correct API wrapper:
if (!response.ok) {
throw new ApiError(...);
}
Query functions need to throw/reject on error.
Retry policy
Not all errors deserve retries.
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;
}
Domain-specific.
A transient network/5xx may retry.
A forbidden request will not become allowed after three attempts.
enabled and lazy thinking
enabled: Boolean(userId)
is good for a query that cannot be identified without userId.
Avoid using enabled: false as a default "imperative fetch button" architecture for every query.
Queries are declarative:
when key/input exists, this cache entry represents this server resource
For click-triggered download or mutation-like operations, a mutation/event may be better.
select
Suppose API returns:
{
"tasks": [...],
"meta": {...}
}
Consumer only needs completed tasks:
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.
Placeholder versus initial data
placeholderData can temporarily display substitute/previous data while real query resolves.
initialData seeds cache as actual data with freshness semantics.
Use intentionally.
Do not use fake placeholder records that users could mistake for authoritative data.
Prefetch and ensure data
Hover:
queryClient.prefetchQuery({
queryKey: taskKeys.detail(id),
queryFn: ({ signal }) => getTask({ id, signal }),
});
Later navigation can reuse cache.
Router integration can call ensureQueryData/prefetch-style APIs depending architecture.
Avoid prefetching large datasets on every hover in constrained networks.
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 helps reuse across:
- component query;
- prefetch;
- router loader.
Keep v5 object API throughout.
Structural sharing
TanStack Query tries to preserve references for unchanged JSON-compatible data.
This can reduce downstream rerenders.
Do not deep-clone every response before storing it:
JSON.parse(JSON.stringify(data))
That destroys reference preservation and types.
Offline/network mode
For applications with offline expectations, query/mutation network mode and persistence become design topics.
Do not claim "offline support" merely because cached data remains visible.
True offline architecture needs:
- persisted cache if reload should work;
- queued writes/conflict rules;
- reconciliation;
- UX for stale data.
Failure clinic
Query data copied to Redux
Duplicate ownership.
Query key omits filter
Wrong cached result reused.
404 treated as success
Fetch wrapper did not throw.
Every query uses same staleTime
Domain freshness ignored.
Refetch on every render
Usually caused by unstable architecture or misunderstanding; use Devtools to inspect.
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
Define:
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'];
},
};
Usage:
useQuery({
queryKey: orderKeys.list({
status,
page,
branchId,
}),
queryFn: ({ signal }) =>
getOrders({
status,
page,
branchId,
signal,
}),
});
Mutation can invalidate:
queryClient.invalidateQueries({
queryKey: orderKeys.lists(),
});
while updating detail directly.
Query key design test
Given two requests, ask:
Could they ever return different data?
If yes, their keys must differ.
Examples:
branch A versus branch B page 1 versus page 2 open versus completed user t1 versus user t2
Missing tenant/branch/user scope in a key can create serious correctness and even privacy problems in multi-tenant clients if cached views are incorrectly reused.
The API must still enforce tenant security server-side, but cache identity should mirror result identity correctly.
Additional depth: Suspense queries, error boundaries, and cache ownership
TanStack Query v5 also provides Suspense-oriented query APIs.
Conceptually:
const { data } = useSuspenseQuery({
queryKey: ['task', taskId],
queryFn: ({ signal }) => getTask({ taskId, signal }),
});
The component assumes data is available during successful render.
Pending behavior moves to Suspense.
Errors can integrate with Error Boundaries.
This changes component branching:
normal useQuery → component handles pending/error/success
versus:
useSuspenseQuery → boundary handles pending/error → component renders successful data
Neither is universally better. Boundary design and router/framework integration matter.
Error reset
If a query error is thrown to Error Boundary, retry/reset needs coordination between:
Error Boundary Query error state
TanStack Query provides reset-boundary utilities/patterns for this.
Do not build a Retry button that resets only the React boundary while the query remains errored with no retry.
Hydration
Server-rendered applications may:
create request-scoped QueryClient prefetch dehydrate send safe state hydrate client
Then useQuery can reuse server-fetched cache.
Be careful not to serialize secrets or cross-user cache state.
Query client lifetime
Browser app:
one QueryClient per application session
Server rendering:
request-scoped server QueryClient
Do not create QueryClient inside ordinary component render:
function App() {
const queryClient = new QueryClient();
because rerenders/remounts can destroy cache identity.
Cache is not a database
Query cache is disposable.
The server remains source of truth.
Do not rely on browser query cache as durable persistence for unsaved critical user work unless you intentionally add persistence/draft architecture.
