How Should I Handle API Errors?
April 7, 202622 min read0 views
How Should I Handle API Errors?
The Problem
Your API returns an error. Now what?
- Show a toast?
- Show inline error?
- Redirect to error page?
- Retry silently?
- Log and ignore?
Without a strategy, you end up with inconsistent UX: some errors crash the page, some show cryptic messages, some silently fail.
The Error Categories
| Category | Example | User Expectation |
|---|---|---|
| Validation | "Email is invalid" | Show inline, let user fix |
| Auth | "Session expired" | Redirect to login |
| Not Found | "Product deleted" | Show message, offer navigation |
| Rate Limit | "Too many requests" | Auto-retry or show wait message |
| Server Error | 500, network failure | Show generic error, offer retry |
| Offline | No connection | Show offline state |
The Architecture
┌─────────────────────────────────────────────────────────────┐
│ API Client │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Fetch/Axios │→ │ Interceptor │→ │ Error Transformer │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Error Boundary │
│ Catches unhandled errors → Shows fallback UI │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Component Level │
│ Handles expected errors → Shows inline feedback │
└─────────────────────────────────────────────────────────────┘
Step 1: Standardize Error Shape
// types/api.ts
interface ApiError {
code: string; // 'VALIDATION_ERROR', 'UNAUTHORIZED', 'NOT_FOUND'
message: string; // User-friendly message
field?: string; // For validation errors
details?: unknown; // Additional context
}
interface ApiResponse<T> {
data?: T;
error?: ApiError;
}
Step 2: Create Error-Aware Fetch
// lib/api.ts
class ApiClient {
private async request<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
// Handle auth errors globally
if (response.status === 401) {
window.location.href = '/login?expired=true';
throw new Error('Unauthorized');
}
const data = await response.json();
// API returned an error
if (!response.ok) {
throw new ApiError(data.error ?? {
code: 'UNKNOWN',
message: 'Something went wrong',
});
}
return data;
}
get<T>(url: string) {
return this.request<T>(url);
}
post<T>(url: string, body: unknown) {
return this.request<T>(url, {
method: 'POST',
body: JSON.stringify(body),
});
}
}
class ApiError extends Error {
code: string;
field?: string;
constructor(error: { code: string; message: string; field?: string }) {
super(error.message);
this.code = error.code;
this.field = error.field;
}
}
export const api = new ApiClient();
Step 3: Handle at the Right Level
Validation Errors → Inline
function SignupForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
async function handleSubmit(formData: FormData) {
try {
await api.post('/auth/signup', Object.fromEntries(formData));
router.push('/dashboard');
} catch (err) {
if (err instanceof ApiError && err.code === 'VALIDATION_ERROR') {
// Show inline on the field
setErrors({ [err.field!]: err.message });
} else {
// Unexpected error — let it bubble to error boundary
throw err;
}
}
}
return (
<form action={handleSubmit}>
<input name="email" />
{errors.email && <span className="error">{errors.email}</span>}
<button type="submit">Sign Up</button>
</form>
);
}
Not Found → Show Message
function ProductPage({ params }: { params: { id: string } }) {
const { data: product, error } = useQuery({
queryKey: ['product', params.id],
queryFn: () => api.get(`/products/${params.id}`),
});
if (error?.code === 'NOT_FOUND') {
return (
<div>
<h1>Product not found</h1>
<p>This product may have been removed.</p>
<Link href="/products">Browse other products</Link>
</div>
);
}
if (error) throw error; // Unexpected — let error boundary handle
return <ProductDetails product={product} />;
}
Server/Network Errors → Error Boundary
// app/error.tsx (Next.js)
'use client';
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div>
<h1>Something went wrong</h1>
<p>We're working on it. Please try again.</p>
<button onClick={reset}>Try again</button>
</div>
);
}
Offline → Show State
function App() {
const isOnline = useOnlineStatus();
if (!isOnline) {
return (
<div className="offline-banner">
You're offline. Some features may not work.
</div>
);
}
return <MainApp />;
}
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
Step 4: Toast for Background Actions
// For actions that don't have inline feedback location
async function deleteItem(id: string) {
try {
await api.delete(`/items/${id}`);
toast.success('Item deleted');
} catch (err) {
toast.error(err.message ?? 'Failed to delete');
}
}
Decision Matrix
| Error Type | Status Code | Handling |
|---|---|---|
| Validation | 400 | Inline error on field |
| Unauthorized | 401 | Redirect to login |
| Forbidden | 403 | Show "no access" message |
| Not Found | 404 | Show "not found" UI |
| Rate Limited | 429 | Show "slow down" or retry |
| Server Error | 500 | Error boundary + retry button |
| Network Error | - | Offline state or retry |
The Anti-Patterns
// ❌ Catching and ignoring
try {
await api.post('/order', data);
} catch {
// User has no idea it failed
}
// ❌ Showing technical errors
catch (err) {
alert(err.message); // "TypeError: Cannot read property 'id' of undefined"
}
// ❌ Every component handling auth
catch (err) {
if (err.status === 401) router.push('/login'); // Duplicated everywhere
}
TL;DR
- Standardize error shape —
{ code, message, field? } - Handle auth globally — In API client, redirect on 401
- Validation inline — Show on the field that failed
- Not found / forbidden — Show contextual UI
- Server errors — Error boundary with retry
- Background actions — Toast notifications
- Network errors — Offline state indicator
What did you think?