Error Handling Patterns
Toast notifications, error boundaries, and graceful degradation.
Overview
On Book Pro uses a layered error handling strategy:
- Toast notifications — User-facing feedback
- Error boundaries — React component crash recovery
- Graceful degradation — Feature fallbacks when services fail
Toast Notification System
Context Setup
tsx
// src/context/ToastContext.tsx
import { createContext, useContext, useState, useCallback } from 'react';
interface Toast {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message?: string;
duration?: number;
}
interface ToastContextValue {
toasts: Toast[];
addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { ...toast, id }]);
// Auto-dismiss
setTimeout(() => {
removeToast(id);
}, toast.duration || 5000);
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
<ToastContainer toasts={toasts} onDismiss={removeToast} />
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) throw new Error('useToast must be used within ToastProvider');
return context;
};Usage
tsx
import { useToast } from '@/context/ToastContext';
export const SaveButton = () => {
const { addToast } = useToast();
const handleSave = async () => {
try {
await saveData();
addToast({
type: 'success',
title: 'Saved successfully',
});
} catch (error) {
addToast({
type: 'error',
title: 'Save failed',
message: error.message,
});
}
};
return <Button onClick={handleSave}>Save</Button>;
};Error Boundary Component
Implementation
tsx
// src/components/ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from 'react';
import { Button, Card } from '@/components/common';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
// Send to error tracking service
// logErrorToService(error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: undefined });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<Card className="p-6 text-center">
<h2 className="text-xl font-bold text-red-600 mb-2">
Something went wrong
</h2>
<p className="text-gray-600 mb-4">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<Button onClick={this.handleReset}>
Try Again
</Button>
</Card>
);
}
return this.props.children;
}
}Usage
tsx
// Wrap feature components
<ErrorBoundary>
<BlockingTracker />
</ErrorBoundary>
// With custom fallback
<ErrorBoundary fallback={<SimplifiedBlockingView />}>
<BlockingTracker />
</ErrorBoundary>Firebase Error Handling
Firestore Errors
typescript
import { FirebaseError } from 'firebase/app';
export const handleFirestoreError = (error: unknown): string => {
if (error instanceof FirebaseError) {
switch (error.code) {
case 'permission-denied':
return 'You don\'t have permission to perform this action';
case 'not-found':
return 'The requested item was not found';
case 'already-exists':
return 'This item already exists';
case 'resource-exhausted':
return 'Too many requests. Please try again later';
case 'unavailable':
return 'Service temporarily unavailable. Changes saved locally';
default:
return `Database error: ${error.message}`;
}
}
if (error instanceof Error) {
return error.message;
}
return 'An unexpected error occurred';
};Storage Errors
typescript
export const handleStorageError = (error: unknown): string => {
if (error instanceof FirebaseError) {
switch (error.code) {
case 'storage/unauthorized':
return 'You don\'t have permission to access this file';
case 'storage/canceled':
return 'Upload was cancelled';
case 'storage/quota-exceeded':
return 'Storage quota exceeded';
case 'storage/object-not-found':
return 'File not found';
default:
return `Storage error: ${error.message}`;
}
}
return 'File operation failed';
};Graceful Degradation
Feature Fallbacks
tsx
export const SoundLibrary = () => {
const [freesoundAvailable, setFreesoundAvailable] = useState(true);
useEffect(() => {
// Check if Freesound API is accessible
checkFreesoundHealth()
.then(() => setFreesoundAvailable(true))
.catch(() => setFreesoundAvailable(false));
}, []);
return (
<div>
<LocalLibrary />
{freesoundAvailable ? (
<FreesoundSearch />
) : (
<Card className="p-4 text-center text-gray-500">
<Icon name="cloud_off" />
<p>Freesound search unavailable. Use local library only.</p>
</Card>
)}
</div>
);
};Loading States
tsx
import { Suspense } from 'react';
import { Skeleton } from '@/components/common';
export const LazyFeature = () => (
<Suspense fallback={<FeatureSkeleton />}>
<HeavyFeatureComponent />
</Suspense>
);
const FeatureSkeleton = () => (
<div className="space-y-4">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
);Async Error Pattern
typescript
// Wrapper for consistent async error handling
export const withErrorHandling = async <T>(
fn: () => Promise<T>,
options?: {
toast?: ReturnType<typeof useToast>;
fallback?: T;
}
): Promise<T | undefined> => {
try {
return await fn();
} catch (error) {
console.error('Operation failed:', error);
if (options?.toast) {
options.toast.addToast({
type: 'error',
title: 'Operation failed',
message: handleFirestoreError(error),
});
}
return options?.fallback;
}
};
// Usage
const data = await withErrorHandling(
() => fetchData(),
{ toast, fallback: [] }
);Logging Best Practices
typescript
// Structured logging for debugging
const log = {
error: (context: string, error: unknown, metadata?: Record<string, unknown>) => {
console.error(`[${context}]`, error, metadata);
// In production, send to logging service
},
warn: (context: string, message: string, metadata?: Record<string, unknown>) => {
console.warn(`[${context}]`, message, metadata);
},
info: (context: string, message: string) => {
if (import.meta.env.DEV) {
console.info(`[${context}]`, message);
}
},
};
// Usage
log.error('SaveDocument', error, { documentId, userId });Best Practices
- Always catch async errors — Use try/catch or .catch()
- Show user-friendly messages — Never expose raw error messages
- Log full error details — Keep technical info in console
- Provide recovery actions — Let users retry or go back
- Test error states — Simulate failures in development
Last updated: January 2026