Skip to content

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

  1. Always catch async errors — Use try/catch or .catch()
  2. Show user-friendly messages — Never expose raw error messages
  3. Log full error details — Keep technical info in console
  4. Provide recovery actions — Let users retry or go back
  5. Test error states — Simulate failures in development

Last updated: January 2026