Error handling in React-based SPA projects is more than just “showing an error message.”
It’s a key part of maintaining a consistent user experience and ensuring maintainability.
In this post, I’ll share a global error handling architecture that I’ve used in real-world projects, including its limitations.
![]()
This architecture is built around Axios and Tanstack Query.
Depending on the project size, it may be overkill, but it’s worth adopting if you have the following requirements:
The error handling process works as follows:
QueryClient.ErrorHandler hook or component handles the final processing (UI, redirect, etc.).The goal here isn’t just to show a one-time error message.
For example, handling useQuery with isError is a one-off error UI:
const { isError } = useQuery({ queryFn: ..., queryKey: [...] });
if (isError) return <>One-time error UI</>;
Global error handling, on the other hand, is about reusable error UIs and centralized redirect logic, for instance:
Define a custom error object that fits your backend response structure:
// types/error.ts
export type ErrorMode = 'page' | 'modal' | 'toast' | 'none';
export interface CustomErrorData {
resultCode?: string;
message?: string;
errorMode?: ErrorMode;
statusCode?: number;
}
// errors/CustomError.ts
export class CustomError extends Error {
public readonly resultCode: string;
public readonly errorMode: ErrorMode;
public readonly statusCode: number;
constructor(data: CustomErrorData = {}) {
const message = data.message || 'An unknown error occurred.';
super(message);
this.name = 'CustomError';
this.resultCode = data.resultCode || 'UNKNOWN_ERROR';
this.errorMode = data.errorMode || 'page';
this.statusCode = data.statusCode || 0;
}
}
Use Axios response interceptors to throw custom errors:
import { ErrorMode } from './error';
declare module 'axios' {
export interface AxiosRequestConfig {
errorMode?: ErrorMode;
}
}
export interface ApiErrorResponse {
error: { message: string };
resultCode: string;
}
axios.interceptors.response.use(
(response: AxiosResponse) => {
// Optional: handle errors even in 200 responses
return response;
},
(error: AxiosError<ApiErrorResponse>) => {
const responseData = error.response?.data;
throw new CustomError({
statusCode: error.response?.status,
resultCode: responseData?.resultCode,
errorMode: error.config?.errorMode,
message: responseData?.error?.message,
});
}
);
Wrap your app with an ErrorProvider to create a global error context:
import { createContext, useContext, useState, ReactNode } from 'react';
import { CustomError } from '../errors/CustomError';
interface ErrorContextValue {
error: CustomError | null;
setError: (error: CustomError | null) => void;
}
const ErrorContext = createContext<ErrorContextValue | undefined>(undefined);
export const useError = (): ErrorContextValue => {
const context = useContext(ErrorContext);
if (!context) throw new Error('useError must be used within an ErrorProvider');
return context;
};
interface ErrorProviderProps { children: ReactNode; }
export const ErrorProvider = ({ children }: ErrorProviderProps) => {
const [error, setError] = useState<CustomError | null>(null);
return (
<ErrorContext.Provider value={{ error, setError }}>
<ErrorBoundary fallback={<DefaultErrorPage />}>
{children}
<ErrorHandler />
</ErrorBoundary>
</ErrorContext.Provider>
);
};
Use QueryClient’s onError hooks to store detected errors in the context:
import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query';
import { useError } from '../contexts/ErrorContext';
import { CustomError } from '../errors/CustomError';
export const QueryProvider: FC<PropsWithChildren> = ({ children }) => {
const { setError } = useError();
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: {
retry: false,
onError: (error) => { if (error instanceof CustomError) setError(error); }
},
},
queryCache: new QueryCache({
onError: (error) => { if (error instanceof CustomError) setError(error); }
}),
mutationCache: new MutationCache({
onError: (error) => { if (error instanceof CustomError) setError(error); }
}),
});
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
Render appropriate UI or redirect based on errorMode:
import { useEffect } from 'react';
import { useError } from '../contexts/ErrorContext';
import { errorMessageMap } from '../constants/errorMessages';
import { useModal } from '../hooks/useModal';
import { useToast } from '../hooks/useToast';
export const ErrorHandler = () => {
const { error, setError } = useError();
const { openModal } = useModal();
const { showToast } = useToast();
useEffect(() => {
if (!error) return;
const handleError = () => {
const errorMessage = errorMessageMap[error.resultCode] || errorMessageMap.UNKNOWN_ERROR;
switch (error.errorMode) {
case 'page':
throw error; // ErrorBoundary fallback
case 'modal':
openModal({
title: errorMessage.title,
content: errorMessage.description,
actionText: errorMessage.actionText,
onClose: () => setError(null)
});
break;
case 'toast':
showToast({ type: 'error', message: errorMessage.description, duration: 3000 });
setError(null);
break;
case 'none':
setError(null);
break;
}
};
handleError();
}, [error, setError, openModal, showToast]);
return null;
};
<ErrorProvider>
<QueryProvider>
<RouterProvider router={router}/>
</QueryProvider>
</ErrorProvider>
Specify error handling behavior per request using the errorMode option:
useQuery({ queryKey: [...], queryFn: () => getSomething({ errorMode: 'toast' }) });
useMutation({ mutationFn: () => postSomething({ errorMode: 'modal' }) });
useQuery({ queryKey: [...], queryFn: () => getSomething2({ errorMode: 'none' }) });
// In this case, the error can be handled directly or ignored
One limitation is that React Router loader functions cannot access context, so consider: