Skip to main content

Error Handling in Async React: Types and Patterns

Async operations in React (like fetching data) often fail—the network is down, the server returns an error, or the user cancels the request. In TypeScript, you must handle errors by typing them correctly. A try/catch block catches errors as unknown, so you must narrow the type before accessing properties like message. Custom error classes make error handling more predictable and type-safe. You can distinguish network errors from validation errors or custom application errors, then handle each type appropriately.

How Do You Type Errors from Async Operations?

When you await a promise that might reject, TypeScript considers the error type as unknown. You must use type guards to narrow it to Error or a more specific type before accessing properties. The safest pattern is to check if the error is an instance of Error using instanceof, then access properties safely.

import { useState, useEffect } from 'react';

interface ApiResponse {
data: unknown;
}

export const SafeAsyncErrorHandling = () => {
const [data, setData] = useState<unknown>(null);
const [error, setError] = useState<string>('');
const [loading, setLoading] = useState(true);

useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch('https://api.example.com/data');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result: ApiResponse = await response.json();
setData(result.data);
} catch (err) {
// err is unknown, so narrow the type before accessing properties
if (err instanceof Error) {
setError(err.message);
} else if (typeof err === 'string') {
setError(err);
} else {
setError('An unexpected error occurred');
}
} finally {
setLoading(false);
}
};

fetchData();
}, []);

if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <div>Data: {JSON.stringify(data)}</div>;
};

The instanceof Error check is the most important guard. It ensures the object has the message, name, and stack properties before you access them.

Creating Custom Error Classes for Typed Error Handling

For better error handling, create custom error classes that represent different failure scenarios. This makes it easier to handle specific errors differently:

// Custom error classes
class NetworkError extends Error {
constructor(message: string, readonly status?: number) {
super(message);
this.name = 'NetworkError';
}
}

class ValidationError extends Error {
constructor(message: string, readonly field: string) {
super(message);
this.name = 'ValidationError';
}
}

class TimeoutError extends Error {
constructor() {
super('Request timeout');
this.name = 'TimeoutError';
}
}

// Fetch with custom errors
async function fetchWithErrors(url: string, timeout: number = 10000): Promise<unknown> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

try {
const response = await fetch(url, { signal: controller.signal });

if (!response.ok) {
throw new NetworkError(`HTTP ${response.status}`, response.status);
}

return await response.json();
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
throw new TimeoutError();
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}

// Handle custom errors
export const HandleCustomErrors = () => {
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [errorType, setErrorType] = useState<string>('');
const [data, setData] = useState<unknown>(null);

const loadData = async () => {
setStatus('loading');
try {
const result = await fetchWithErrors('https://api.example.com/data');
setData(result);
setStatus('success');
} catch (err) {
setStatus('error');

// Handle different error types
if (err instanceof NetworkError) {
setErrorType(`Network failed (${err.status}): ${err.message}`);
} else if (err instanceof ValidationError) {
setErrorType(`Validation error on ${err.field}: ${err.message}`);
} else if (err instanceof TimeoutError) {
setErrorType('Request timed out, please try again');
} else if (err instanceof Error) {
setErrorType(err.message);
} else {
setErrorType('Unknown error');
}
}
};

return (
<div>
<button onClick={loadData} disabled={status === 'loading'}>
Load Data
</button>
{status === 'loading' && <p>Loading...</p>}
{status === 'success' && <p>Data: {JSON.stringify(data)}</p>}
{status === 'error' && <p style={{ color: 'red' }}>Error: {errorType}</p>}
</div>
);
};

Custom error classes make error handling explicit. When you see catch (err), you immediately know you can instanceof against NetworkError, ValidationError, or TimeoutError to determine how to respond.

Type-Safe Error State with Discriminated Unions

For more complex error scenarios, use a discriminated union type for your error state. This ensures you handle all error types:

// Discriminated union for errors
type ErrorState =
| { type: 'none' }
| { type: 'network'; status: number; message: string }
| { type: 'timeout' }
| { type: 'validation'; field: string; message: string }
| { type: 'unknown'; message: string };

interface LoadingState {
status: 'idle' | 'loading' | 'success';
data: unknown;
error: ErrorState;
}

export const DiscriminatedErrorHandling = () => {
const [state, setState] = useState<LoadingState>({
status: 'idle',
data: null,
error: { type: 'none' },
});

const handleError = (err: unknown) => {
if (err instanceof NetworkError) {
setState(prev => ({
...prev,
error: { type: 'network', status: err.status || 0, message: err.message },
}));
} else if (err instanceof TimeoutError) {
setState(prev => ({
...prev,
error: { type: 'timeout' },
}));
} else if (err instanceof Error) {
setState(prev => ({
...prev,
error: { type: 'unknown', message: err.message },
}));
}
};

const renderError = () => {
const { error } = state;

// TypeScript ensures all error.type values are handled
switch (error.type) {
case 'none':
return null;
case 'network':
return <p style={{ color: 'red' }}>Network error ({error.status}): {error.message}</p>;
case 'timeout':
return <p style={{ color: 'orange' }}>Request timed out</p>;
case 'validation':
return <p style={{ color: 'red' }}>Invalid {error.field}: {error.message}</p>;
case 'unknown':
return <p style={{ color: 'red' }}>Error: {error.message}</p>;
}
};

return (
<div>
{state.status === 'loading' && <p>Loading...</p>}
{state.status === 'success' && <p>Data loaded</p>}
{renderError()}
</div>
);
};

TypeScript's switch statement exhaustiveness checking ensures you handle every error type. If you add a new error type and forget to handle it in the switch, TypeScript warns you.

Typing Promise.allSettled for Multiple Async Operations

When you run multiple async operations concurrently, use Promise.allSettled() to wait for all to complete, even if some fail:

async function fetchMultiple(urls: string[]): Promise<Array<{ success: boolean; data?: unknown; error?: Error }>> {
const promises = urls.map(url =>
fetch(url).then(res => (res.ok ? res.json() : Promise.reject(new NetworkError(res.statusText))))
);

const results = await Promise.allSettled(promises);

return results.map(result => {
if (result.status === 'fulfilled') {
return { success: true, data: result.value };
} else {
return { success: false, error: result.reason };
}
});
}

export const FetchMultiple = () => {
const [results, setResults] = useState<Array<{ success: boolean; data?: unknown; error?: Error }>>([]);

const loadMultiple = async () => {
const data = await fetchMultiple([
'https://api.example.com/users',
'https://api.example.com/products',
'https://api.example.com/orders',
]);
setResults(data);
};

return (
<div>
<button onClick={loadMultiple}>Load All</button>
<ul>
{results.map((result, idx) => (
<li key={idx}>
{result.success ? 'Success' : `Error: ${result.error?.message}`}
</li>
))}
</ul>
</div>
);
};

Promise.allSettled() returns an array of objects with status and either value (on success) or reason (on failure). This lets you handle partial failures gracefully.

Key Takeaways

  • Always narrow error types in catch blocks with instanceof checks before accessing properties.
  • Create custom error classes to distinguish different failure scenarios (network, timeout, validation).
  • Use discriminated unions for error state to ensure you handle all error types.
  • Promise.allSettled() lets you wait for multiple async operations and handle failures gracefully.
  • TypeScript's exhaustiveness checking on switch statements ensures you handle all error cases.

Frequently Asked Questions

Why is error typed as unknown in catch blocks?

JavaScript allows any value to be thrown, not just Error objects. A function could throw a string, number, or custom object. TypeScript conservatively types it as unknown, forcing you to narrow before using.

Should I always use custom error classes?

For simple applications, built-in Error is sufficient. For complex applications with multiple failure modes, custom classes make error handling clearer and more maintainable.

How do I know what error to expect from a specific API?

Read the API documentation. Most APIs document HTTP status codes and error response formats. Use Zod to validate error responses so you know the shape before handling.

Can I combine error handling with retries?

Yes, wrap the fetch in a retry loop. On certain errors (like timeout), retry after a delay. On others (like 400), fail immediately.

How do I differentiate network errors from server errors?

A NetworkError is thrown before reaching the server (DNS failure, no internet). A server error is an HTTP status code (500, 404, etc.). Handle them differently: retry network errors, show user-friendly messages for server errors.

Further Reading