Proactive error handling for React and vanilla JS frontends — every data-fetching component gets loading, error, and empty states, error boundaries, fetch error handling, form validation, optimistic rollback
90
84%
Does it follow best practices?
Impact
100%
1.38xAverage score across 5 eval scenarios
Passed
No known issues
Core rule: every component that fetches data needs loading, error, and empty states. Always. Even when the user doesn't ask for them.
Handle errors gracefully in frontend apps — show useful messages, never blank-screen, recover when possible.
Any component that fetches data MUST render all four states:
role="alert".Never skip any of these. A missing loading state means a blank flash. A missing error state means a white screen. A missing empty state means a confusing blank page.
Every fetch should handle three failure modes — network error, HTTP error, and parse error:
// src/api.ts
export async function apiRequest<T>(path: string, options?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...options,
});
} catch {
// Network error — server unreachable, no internet, DNS failure
throw new Error('Unable to connect to server. Check your connection.');
}
if (!res.ok) {
// HTTP error — server returned 4xx/5xx
const body = await res.json().catch(() => null);
const message = body?.error?.message || body?.message || `Request failed (${res.status})`;
throw new Error(message);
}
// Parse error — response isn't valid JSON
try {
const body = await res.json();
return body.data ?? body;
} catch {
throw new Error('Invalid response from server.');
}
}function UserProfile() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await apiRequest<User>('/api/users/me');
setUser(data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Something went wrong');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
if (loading) return <div aria-busy="true">Loading profile...</div>;
if (error) return (
<div role="alert">
<p>{error}</p>
<button onClick={load}>Try again</button>
</div>
);
if (!user) return <p>No profile found.</p>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}if (items.length === 0) return <p>No items found. Create your first one!</p>;Catch render-time errors so one broken component doesn't blank the whole page. Every React app must have at least a top-level error boundary, and ideally one per route.
// src/components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react';
interface Props { children: ReactNode; fallback?: ReactNode; }
interface State { error: Error | null; }
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('Component error:', error, info.componentStack);
// Send to error reporting service in production
}
render() {
if (this.state.error) {
return this.props.fallback || (
<div role="alert" style={{ padding: '1rem', border: '1px solid #e00', borderRadius: '4px' }}>
<h2>Something went wrong</h2>
<p>{this.state.error.message}</p>
<button onClick={() => this.setState({ error: null })}>Try again</button>
</div>
);
}
return this.props.children;
}
}Wrap route-level components:
function App() {
return (
<ErrorBoundary>
<Router>
<ErrorBoundary fallback={<p>This section failed to load.</p>}>
<Route path="/profile" element={<UserProfile />} />
</ErrorBoundary>
<ErrorBoundary fallback={<p>Dashboard unavailable.</p>}>
<Route path="/dashboard" element={<Dashboard />} />
</ErrorBoundary>
</Router>
</ErrorBoundary>
);
}Catch unhandled errors and promise rejections at the window level as a safety net:
// src/global-error-handler.ts
window.addEventListener('error', (event) => {
console.error('Unhandled error:', event.error);
// Report to error tracking service
});
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection:', event.reason);
// Report to error tracking service
});Forms need both client-side validation and server error handling:
function ContactForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// Client-side validation
const newErrors: Record<string, string> = {};
if (!formData.get('email')) newErrors.email = 'Email is required';
if (!formData.get('message')) newErrors.message = 'Message is required';
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setSubmitting(true);
setSubmitError(null);
try {
await apiRequest('/api/contact', {
method: 'POST',
body: JSON.stringify(Object.fromEntries(formData)),
});
} catch (err: unknown) {
setSubmitError(err instanceof Error ? err.message : 'Failed to send message');
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} noValidate>
{submitError && <div role="alert">{submitError}</div>}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email"
aria-invalid={!!errors.email} aria-describedby="email-error" />
{errors.email && <span id="email-error" role="alert">{errors.email}</span>}
{/* ... more fields ... */}
<button type="submit" disabled={submitting}>
{submitting ? 'Sending...' : 'Send'}
</button>
</form>
);
}When updating data optimistically, always handle the rollback case:
async function toggleFavorite(itemId: string) {
// Save previous state for rollback
const previousItems = [...items];
// Optimistic update
setItems(items.map(item =>
item.id === itemId ? { ...item, favorite: !item.favorite } : item
));
try {
await apiRequest(`/api/items/${itemId}/favorite`, { method: 'POST' });
} catch (err) {
// Rollback on failure
setItems(previousItems);
showToast('Failed to update. Please try again.');
}
}For critical operations, implement automatic retry with backoff:
async function fetchWithRetry<T>(
fn: () => Promise<T>,
retries = 3,
delay = 1000
): Promise<T> {
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === retries - 1) throw err;
await new Promise(resolve => setTimeout(resolve, delay * (attempt + 1)));
}
}
throw new Error('Unreachable');
}Never show raw error objects, stack traces, or technical messages to users:
function getUserMessage(error: unknown): string {
const msg = error instanceof Error ? error.message.toLowerCase() : '';
if (msg.includes('unable to connect') || msg.includes('network') || msg.includes('failed to fetch')) {
return 'Can\'t reach the server. Check your internet connection.';
}
if (msg.includes('not found') || msg.includes('404')) {
return 'The item you\'re looking for doesn\'t exist.';
}
if (msg.includes('unauthorized') || msg.includes('401')) {
return 'Please sign in to continue.';
}
if (msg.includes('forbidden') || msg.includes('403')) {
return 'You don\'t have permission to do that.';
}
if (msg.includes('rate limit') || msg.includes('429') || msg.includes('too many')) {
return 'Too many requests. Please wait a moment and try again.';
}
return error instanceof Error && error.message
? error.message
: 'Something went wrong. Please try again.';
}For non-blocking errors (failed action, but the page still works):
function showToast(message: string, type: 'error' | 'success' = 'error') {
const toast = document.getElementById('toast')!;
toast.textContent = message;
toast.className = `toast toast-${type} toast-visible`;
setTimeout(() => { toast.className = 'toast'; }, 5000);
}
// HTML: <div id="toast" role="status" aria-live="polite" class="toast"></div>Use toasts for: failed non-critical actions, success confirmations, transient network issues. Use full-page error states for: failed initial data load, auth failures, critical errors.
async function loadOrders() {
const container = document.getElementById('orders')!;
container.innerHTML = '<p aria-busy="true">Loading orders...</p>';
try {
const orders = await apiRequest<Order[]>('/api/orders');
if (orders.length === 0) {
container.innerHTML = '<p>No orders yet. Place your first order!</p>';
return;
}
container.innerHTML = renderOrders(orders);
} catch (err) {
container.innerHTML = `
<div role="alert" class="error-message">
<p>${escapeHtml(getUserMessage(err))}</p>
<button onclick="loadOrders()">Try again</button>
</div>
`;
}
}role="alert" for accessibilityevals
scenario-1
scenario-2
scenario-3
scenario-4
scenario-5
skills
frontend-error-handling
verifiers