This page maps the routing decision from Custom Hooks for Async Error Catching onto TanStack Query, where the cache adds a step most teams miss.
The exact problem #
An orders table uses useQuery. The endpoint returns 500. The component renders error inline — fine. Then a second query on the same page also fails, and a third, and the page fills with three different error cards in three different visual styles, while a fourth component that genuinely cannot render without data shows an empty table with no explanation at all.
Meanwhile the boundary that is on the page never fires, because by default TanStack Query returns errors as state rather than throwing them. And when a team turns on throwOnError globally, the retry button in their fallback stops working: resetting the boundary re-renders a component that reads the same cached error and rethrows instantly.
Zero-to-working configuration #
// queryClient.ts
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
queryCache: new QueryCache({
// One reporting path for every query failure, however it is displayed.
onError: (error, query) => {
reportCrash({
scope: `query:${String(query.queryKey[0])}`,
message: error instanceof Error ? error.message : String(error),
queryKey: JSON.stringify(query.queryKey).slice(0, 200),
status: (error as { status?: number }).status,
});
},
}),
mutationCache: new MutationCache({
onError: (error, _vars, _ctx, mutation) => {
reportCrash({
scope: `mutation:${String(mutation.options.mutationKey?.[0] ?? 'unknown')}`,
message: error instanceof Error ? error.message : String(error),
});
},
}),
defaultOptions: {
queries: {
// Transient failures should never reach the user; permanent ones fast.
retry: (failureCount, error) => {
const status = (error as { status?: number }).status ?? 0;
if (status >= 400 && status < 500) return false; // never retry client errors
return failureCount < 2;
},
retryDelay: (attempt) => Math.min(400 * 2 ** attempt, 2000),
// Boundary for anything the component cannot render around.
throwOnError: (error) => {
const status = (error as { status?: number }).status ?? 0;
return status === 0 || status >= 500; // network + server errors
},
staleTime: 30_000,
},
mutations: {
// Mutations stay inline: a failed save belongs next to the form.
throwOnError: false,
retry: false,
},
},
});
Making the boundary’s retry actually refetch #
This is the piece that is specific to a caching data layer. The boundary reset must also reset the query, or the component rethrows the cached error immediately.
// QueryBoundary.tsx
import { QueryErrorResetBoundary } from '@tanstack/react-query';
export function QueryBoundary({ scope, children }: { scope: string; children: React.ReactNode }) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<RetryableBoundary
scope={scope}
// Clear the failed query state BEFORE the subtree remounts.
onReset={reset}
fallback={<SectionError scope={scope} />}
>
{children}
</RetryableBoundary>
)}
</QueryErrorResetBoundary>
);
}
reset() clears the error state of every query that failed inside this boundary’s subtree, so the remount issues a genuine refetch. Without it the retry is the dead button described in Adding a Retry Button That Recovers Without a Full Reload.
Step-by-step #
- Make
throwOnErrora predicate. A boolean forces one policy on every query; a predicate lets 404 render “no results” inline while 500 goes to a boundary. - Turn off retry for 4xx. Retrying a 403 three times wastes two seconds of the user’s life to produce the same 403.
- Wrap with
QueryErrorResetBoundarywherever you throw. The pairing is mandatory, not optional:throwOnErrorwithoutresetproduces unrecoverable fallbacks. - Keep mutations inline. A failed save must not replace the form that holds the user’s unsaved input — the loss described in Form State Recovery & Multi-Step Wizards.
- Report from the caches.
QueryCache.onErrorandMutationCache.onErrorfire once per failure regardless of display, which keeps reporting complete without scattering handlers through components. - Include the query key in the report. It is the closest thing to a component stack for a data failure, and it makes the fingerprinting in Deduplicating Error Noise with Fingerprinting and Sampling meaningful.
| Query situation | throwOnError |
Retry | Display |
|---|---|---|---|
| Detail page’s primary record, 500 | true | 2 | Boundary fallback with retry |
| Detail page’s primary record, 404 | false | 0 | “Not found” state with a link back |
| Sidebar recommendations, any failure | false | 1 | Inline empty state, silent |
| Auth-required query, 401 | false | 0 | Escalate to session handling |
| Paginated table, page 3 fails | false | 2 | Keep page 2 visible, inline banner |
| Background refetch of visible data | false | 2 | Silent; keep the stale data on screen |
The last row matters for perceived reliability: a background refetch that fails should never destroy the data already on screen. TanStack Query’s default behaviour is correct here — the failure surfaces as isError while data remains — and turning on throwOnError globally breaks it by throwing away good data because a refresh failed.
Verification #
- Force a 500 on a primary query. After two retries the boundary fallback appears; the retry button then issues a fresh request — check the network panel for the new call.
- Force a 404. No boundary, no retries, an inline “not found” state.
- Fail a background refetch. The visible rows must remain, with a non-destructive banner.
- Fail a mutation. The form stays mounted with its values, an inline message appears, and one
mutation:scoped report is sent. - Count reports. One per failure. If a failure that hits a boundary is reported twice, the boundary is reporting on top of the cache handler — pick one.
One more configuration detail is worth stating explicitly: the predicate you pass to throwOnError runs for refetches as well as first loads, so a background refresh of a healthy query can throw and replace working content. Guarding the predicate on whether data already exists — throw only when there is nothing to show — keeps stale data on screen while still routing genuine first-load failures to the boundary.
Frequently asked questions #
Why does my boundary retry immediately fail again with React Query?
Because the query is still in an error state in the cache, so the remount rethrows it. Wrap in QueryErrorResetBoundary and call reset in the retry handler.
Should every query use throwOnError?
No. Use it where the component cannot render meaningfully without the data. A predicate — throw on 5xx, handle 4xx inline — is the usual default.
How do I stop retries from delaying the error for ten seconds?
Make retry conditional on error type: never retry 4xx, retry network and 5xx twice with a short capped backoff.
Related #
- Custom Hooks for Async Error Catching — the routing decision this configures
- Cancelling Stale Async State Updates in a useAsync Hook — the same concerns without a data library
- Adding a Retry Button That Recovers Without a Full Reload — why the reset must clear the cache first