Mastering Global Error Handling in React Query — Consolidating 30 Scattered onError Blocks
React Query (TanStack Query v5) global errors: QueryCache, MutationCache, meta cut onError duplication—401/403/5xx; split UI copy and logs.

This is a record of a practical refactoring journey using TanStack Query v5's
QueryCache,MutationCache, and themetafield to consolidate fragmented error handling into one place. If you're struggling with global error handling in React Query, I hope our trial and error can be of some help.
Hi, I'm INSIK, a frontend developer at the BAS KOREA IT Team.
Are you using React Query effectively? It’s a natural choice for new projects, but for a service that's been running for a year or two, you might be feeling overwhelmed by the onError callbacks embedded in every component, thinking, "I really need to clean this up someday..."
The solution we are building, "Flow MATE," is a B2B trade operation tool that flows from Inquiry → Offer → Order → Logistics → Invoice. Because the workflow is long, if even one API fails silently in the middle, the entire next step of the user's task comes to a halt. When we debugged CS tickets saying, "I can't see the data," we often found that the essence of the problem wasn't the API failure itself, but that the error wasn't properly communicated to the user.
In this post, I'll share the process of gathering fragmented error handling into one place using TanStack Query v5's QueryCache / MutationCache and the meta field, including the roadblocks we hit and the discussions we had within the team.
This will be helpful for those who:
Want to clean up repetitive
onErrorblocks in everyuseQueryAre looking for global handling patterns based on status codes like 401/403/5xx
Are wondering how to make global handling and individual handling coexist
1. The Beginning — 30 onError blocks embedded in components
When we first introduced React Query, we didn't think much about it. The useQuery hook kindly provided an onError option, and I just wrote the code to pop up a toast right there.
// ❌ Before — A pattern repeated on every page
const { data } = useQuery({
queryKey: ["inquiry", id],
queryFn: () => fetchInquiry(id),
onError: (error) => {
if (isAxiosError(error) && error.response?.status === 401) {
authStore.logout();
router.replace("/login");
return;
}
toast.error("Failed to load inquiry information.");
},
});
It wasn't a problem when there were only one or two. But as the domains expanded to include Inquiry, Offer, Order, Logistics, and Invoice, this similar block started appearing in over 30 places throughout the project.
One day, when the backend team announced, "The 401 response spec is changing; a reason field will be added to the body," I realized that there were not one, but thirty places where I'd have to handle this change.
Before raising a PR, I had a hunch and ran it by an AI agent.
Me: The 401 response spec changed. Go through all the
onErrorblocks and fix them.AI: Sure. I see about thirty places in the project handling 401 in
onError. I'll start fixing them one by one.Me: Wait, that's going to take forever. Isn't there a way to handle this all at once?
AI: Since every component currently holds its own error handling, you're in a state where you have to touch thirty places every time the spec changes. It makes sense to consolidate these repetitive tasks into one place.
Me: True. If I just fix the 401s now, I'll have to go through another thirty places if the 5xx format changes later.
AI: Exactly. And I noticed the toast calls vary by file—some use
toast.error, others useSwal.fire—so even for the same error, the UI tone is inconsistent across pages.Me: So this fix is just patching the symptom. The real problem is that I put the error handling inside the components in the first place.
AI: I agree. TanStack Query allows you to receive errors all at once at the
QueryCache/MutationCachelevel, so I suggest moving the architecture itself this time.

At that moment, it clicked. What I needed to fix wasn't just a line of code for 401, but the structure of where we put error handling. That conversation was the starting point for this improvement.
2. Setting the Direction — "Let components focus on the UI"
The reasoning was simple. A page component only needs to know what to show. It doesn't need to know that it should log out on a 401 or log to Sentry on a 5xx. That is the responsibility of the application infrastructure.
So, I decided to separate error handling into three layers.
Layer | Responsibility | Location
--- | --- | ---
Global Handler | Default actions by HTTP status code, logging, authentication | QueryCache / MutationCache onError
Domain Context | "What message should this query show?" | meta.errorMessage, etc.
Component | UI branching like loading/empty states | useQuery return values
Once I envisioned this structure, the implementation went surprisingly fast.
3. Step 1 — Gathering error paths into QueryCache
In TanStack Query v5, you can inject a QueryCache and MutationCache when creating a QueryClient. Each onError becomes a single gateway through which all query/mutation errors pass. Errors flow here even if you don't set anything in the individual hooks.
// src/lib/queryClient.ts
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
GlobalErrorHandler.handle(error, query.meta);
},
}),
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
GlobalErrorHandler.handle(error, mutation.meta);
},
}),
});
The key is the second argument: query.meta / mutation.meta. This field acts as the only communication channel between the global handler and individual queries. Any worry about "losing flexibility by handling everything globally" is resolved right here.
4. Step 2 — Passing context per query with meta
Some queries should fail silently (e.g., background prefetch), while others must get user confirmation via a modal. To allow the global handler to know these different contexts for each query, I first extended the meta type.
// src/lib/react-query.d.ts
import "@tanstack/react-query";
declare module "@tanstack/react-query" {
interface Register {
queryMeta: {
errorMessage?: string;
ignoreGlobalError?: boolean;
feedback?: "toast" | "modal" | "silent";
};
mutationMeta: {
errorMessage?: string;
successMessage?: string;
ignoreGlobalError?: boolean;
feedback?: "toast" | "modal" | "silent";
};
}
}
By extending the type, autocomplete now pops up after meta: when writing a new query. The effect of not having to dig through code to see "could I use this option?" was quite significant.
The actual usage becomes this simple:
// 👍 After — The component only declares the message
const { data } = useQuery({
queryKey: ["inquiry", id],
queryFn: () => fetchInquiry(id),
meta: {
errorMessage: "Failed to load inquiry information. Please try again in a moment.",
},
});
That was the moment 30+ lines of duplicate blocks shrank to a single meta line.

5. Step 3 — Branching by status code in GlobalErrorHandler
The global handler isn't just a toast launcher. It acts as a control tower governing the application state based on backend response protocols. I organized the paths by status code based on Axios errors like this:
// src/lib/GlobalErrorHandler.ts
export class GlobalErrorHandler {
static handle(error: unknown, meta?: QueryMeta | MutationMeta) {
if (meta?.ignoreGlobalError) return;
if (!isAxiosError(error)) {
Sentry.captureException(error);
this.toast(meta?.errorMessage ?? "An unknown error occurred.");
return;
}
const status = error.response?.status;
switch (status) {
case 401:
return this.handleUnauthorized();
case 403:
return this.handleForbidden(meta);
case 429:
return this.toast("Too many requests. Please try again in a moment.");
case 500:
case 502:
case 503:
case 504:
Sentry.captureException(error);
return this.toast("We couldn't process your request due to a temporary server issue.");
default:
return this.toast(
meta?.errorMessage ?? error.response?.data?.message ?? "Could not process the request.",
);
}
}
private static handleUnauthorized() {
AuthService.clear();
window.location.replace("/login?reason=expired");
}
// ... etc.
}
One principle I focused on here was "separating the message shown to the user from the log sent to the developer." The user only needs to see "We're sorry for the inconvenience," while the stack trace and request URL are quietly sent to Sentry.
Summary of Responses by Status Code
HTTP Situation | Global Action | User Feedback
--- | --- | ---
401 Token Expired | Reset Auth State → Go to /login | "Session expired. Please log in again."
403 No Permission | Return to previous path | "You do not have permission to access this feature." (Modal)
4xx Validation/Business Error | Server message first, else meta.errorMessage | Toast
429 Request Limited | Cooldown guidance | "Please try again in a moment."
5xx Server Failure | Send to Sentry | "A temporary server issue occurred."
6. The Pitfall — "Please exclude this query from global handling"

Paradoxically, the most frequent request I received after setting up global handling was, "Please exclude this part from global handling."
For example, on the Invoice page, we make parallel requests for preview data using useQueries. If even one of these fails and a full-screen toast pops up, the screen becomes a mess. But we couldn't just give up on global handling either.
The solution was meta.ignoreGlobalError.
useQueries({
queries: invoiceIds.map((id) => ({
queryKey: ["invoice-preview", id],
queryFn: () => fetchInvoicePreview(id),
meta: { ignoreGlobalError: true }, // Display only as inline error UI
})),
});
After creating this pattern, I was finally able to establish a rule: "The opposite of global handling isn't 'individual handling,' but an 'explicit override.'" You can handle it individually, but that exception must be clearly visible in the meta.
7. After Implementation — Results in Numbers
Here are the results after observing for about a month.
Lines of error handling code: ~1,200 → 180 (−85%)
Average PR lines for new API integration: ~40% decrease (effect of removing repetitive code)
Error-related CS tickets: Half the level of the previous quarter. The biggest change was that users now know "what worked and what didn't."
Better than the numbers was the feeling that "I don't have to worry about error handling when creating a new page." Now, I just write one line for the error message next to useQuery and get right back to the business logic.
8. Summary — The Core Takeaways

Since the post was long, let's summarize the skeleton of today's story.
One Gateway — Consolidate all errors in the
onErrorofQueryCache/MutationCache.Context via
meta— Components only declare the message; interpretation is left to the handler.Explicit Exceptions — Leave queries that opt-out, like
ignoreGlobalError, visible in the code.Separate User Messages and Dev Logs — Give the user guidance, give Sentry the stack trace.
Closing — Errors are the final conversation with the user
The thought that occurred to me most while building global error handling was that "error messages are ultimately words the service speaks to the user." In a domain like trade operations where one mistake can cascade like dominoes, an error shouldn't just be a popup; it should be a "guide for the next action."
What started as a personal desire for cleanup has now become a natural standard for Flow MATE. In the future, I plan to expand this into an integrated Error Boundary that covers runtime errors as well as network errors, and a self-healing UI where users can directly retry or recover.
Thank you for reading this long post. I hope the onError blocks disappear one by one from your projects as well!
Best regards, INSIK from the BAS KOREA IT Chapter Frontend.
Tags: React React Query TanStack Query TypeScript Frontend Error Handling Web Development


