Custom Hooks for Async Error Catching

1. Architectural Context: The Async Error Propagation Gap

Traditional component trees rely heavily on synchronous error handling mechanisms that fail to capture promise rejections, network timeouts, or deferred execution failures. While synchronous crashes halt the render pipeline immediately, asynchronous failures often escape the component lifecycle entirely, resulting in unhandled promise rejections that degrade session stability and obscure root causes. Establishing a hook-based interception layer bridges this gap by normalizing asynchronous execution into predictable, observable state transitions.

To prevent runtime failures from cascading into unhandled rejections, teams should adopt a structured Framework-Specific Crash Recovery & Error Handlers baseline. This architecture intercepts failures at the operation level before they reach the global scope, ensuring component context is preserved.

// Standardized async error payload interface
export interface AsyncErrorPayload {
  code: string;
  message: string;
  stack?: string;
  context: Record<string, unknown>;
  timestamp: number;
  severity: 'info' | 'warning' | 'critical';
}

export const normalizeError = (
  err: unknown,
  context: Record<string, unknown> = {}
): AsyncErrorPayload => {
  const isAxiosError = (e: any): e is { response?: { status: number; data: unknown } } =>
    typeof e === 'object' && e !== null && 'isAxiosError' in e;

  if (isAxiosError(err)) {
    return {
      code: `HTTP_${err.response?.status ?? 500}`,
      message: err.response?.data?.message ?? 'Network request failed',
      stack: err.stack,
      context: { ...context, endpoint: err.config?.url },
      timestamp: Date.now(),
      severity: err.response?.status >= 500 ? 'critical' : 'warning',
    };
  }

  return {
    code: 'UNKNOWN_ERROR',
    message: err instanceof Error ? err.message : String(err),
    stack: err instanceof Error ? err.stack : undefined,
    context,
    timestamp: Date.now(),
    severity: 'critical',
  };
};

Edge Case: Unhandled promise rejections originating in Web Workers bypass the main thread’s error boundaries. Workers must implement explicit postMessage error routing to the main thread. Pitfall: Over-reliance on window.addEventListener('unhandledrejection') strips component context, making it impossible to correlate failures with specific UI states or user sessions.

2. Core Hook Architecture & State Synchronization

A production-grade async wrapper must enforce deterministic state transitions, prevent stale closures, and guarantee cleanup on unmount. The useAsyncCatch pattern centralizes execution logic, replacing scattered try/catch blocks with a unified state machine.

For the canonical React implementation pattern, refer to Catching async errors in custom React hooks.

import { useState, useRef, useCallback, useEffect } from 'react';

export interface AsyncState<T> {
  data: T | null;
  isLoading: boolean;
  error: AsyncErrorPayload | null;
}

export function useAsyncCatch<T>(
  asyncFn: (signal: AbortSignal) => Promise<T>,
  options: { immediate?: boolean; onError?: (err: AsyncErrorPayload) => void } = {}
) {
  const [state, setState] = useState<AsyncState<T>>({
    data: null,
    isLoading: false,
    error: null,
  });
  const abortControllerRef = useRef<AbortController | null>(null);
  const isMountedRef = useRef(true);

  useEffect(() => {
    isMountedRef.current = true;
    return () => {
      isMountedRef.current = false;
      abortControllerRef.current?.abort();
    };
  }, []);

  const execute = useCallback(
    async (resetError = true) => {
      abortControllerRef.current?.abort();
      const controller = new AbortController();
      abortControllerRef.current = controller;

      if (resetError) setState((prev) => ({ ...prev, error: null, isLoading: true }));
      else setState((prev) => ({ ...prev, isLoading: true }));

      try {
        const result = await asyncFn(controller.signal);
        if (isMountedRef.current && !controller.signal.aborted) {
          setState({ data: result, isLoading: false, error: null });
        }
      } catch (err) {
        if (controller.signal.name === 'AbortError') return;
        const normalized = normalizeError(err);
        if (isMountedRef.current) {
          setState((prev) => ({ ...prev, isLoading: false, error: normalized }));
          options.onError?.(normalized);
        }
      }
    },
    [asyncFn, options.onError]
  );

  useEffect(() => {
    if (options.immediate) execute();
  }, [execute, options.immediate]);

  return { ...state, execute, cancel: () => abortControllerRef.current?.abort() };
}

Edge Case: Rapid component remounts trigger race conditions where stale promises resolve after unmount. The AbortController and isMountedRef guard prevent state updates on detached components. Pitfall: Failing to reset error state on subsequent triggers leaves the UI locked in an error state. Memory leaks occur when unresolved promises retain references to component instances.

3. Framework Handler Integration & Boundary Bridging

Isolated hook errors must bubble up to declarative UI boundaries to trigger fallback rendering without crashing the entire application tree. By lifting hook-level error state into a React Context, you can bridge asynchronous execution failures with synchronous error boundaries.

Integration with React Error Boundary Implementation demonstrates how to map async payloads to componentDidCatch or getDerivedStateFromError, ensuring consistent fallback routing.

import { createContext, useContext, useState, type ReactNode } from 'react';

interface ErrorBoundaryContextValue {
  criticalError: AsyncErrorPayload | null;
  setCriticalError: (err: AsyncErrorPayload | null) => void;
}

const ErrorBoundaryContext = createContext<ErrorBoundaryContextValue>({
  criticalError: null,
  setCriticalError: () => {},
});

export const ErrorBoundaryProvider = ({ children }: { children: ReactNode }) => {
  const [criticalError, setCriticalError] = useState<AsyncErrorPayload | null>(null);
  return (
    <ErrorBoundaryContext.Provider value={{ criticalError, setCriticalError }}>
      {children}
    </ErrorBoundaryContext.Provider>
  );
};

export const useErrorBoundary = () => useContext(ErrorBoundaryContext);

// Mapping hook errors to boundary state
export const useBoundaryMappedAsync = <T,>(
  asyncFn: (signal: AbortSignal) => Promise<T>
) => {
  const { setCriticalError } = useErrorBoundary();
  const asyncState = useAsyncCatch(asyncFn, {
    onError: (err) => {
      if (err.severity === 'critical') setCriticalError(err);
    },
  });
  return asyncState;
};

Edge Case: Nested error boundaries can swallow hook-level errors prematurely if getDerivedStateFromError lacks severity filtering. Always propagate non-critical errors to the nearest appropriate fallback. Pitfall: Blocking the UI thread with synchronous error serialization during boundary transitions causes jank. Maintain consistent error object shapes across boundaries to prevent mismatched fallback rendering.

4. Cross-Framework Parity & Global State Management

Reactive ecosystems require equivalent async interception patterns that align with their update cycles. Translating the React hook architecture into Vue’s Composition API and Svelte’s store system ensures consistent telemetry and recovery across heterogeneous stacks. Compare state synchronization strategies with Vue & Svelte Global Error Handlers to maintain uniform error routing.

// Vue 3 Composition API Equivalent
import { ref, onUnmounted } from 'vue';

export function useVueAsyncCatch<T>(asyncFn: () => Promise<T>) {
  const data = ref<T | null>(null);
  const isLoading = ref(false);
  const error = ref<AsyncErrorPayload | null>(null);
  let controller: AbortController | null = null;

  const execute = async () => {
    controller?.abort();
    controller = new AbortController();
    isLoading.value = true;
    error.value = null;

    try {
      const result = await asyncFn();
      if (!controller.signal.aborted) data.value = result;
    } catch (err) {
      if (controller.signal.name !== 'AbortError') {
        error.value = normalizeError(err);
      }
    } finally {
      isLoading.value = false;
    }
  };

  onUnmounted(() => controller?.abort());
  return { data, isLoading, error, execute };
}
// Svelte Store Equivalent
import { writable } from 'svelte/store';

export function createSvelteAsyncCatch<T>(asyncFn: () => Promise<T>) {
  const state = writable({ data: null, isLoading: false, error: null });
  let controller: AbortController | null = null;

  const execute = async () => {
    controller?.abort();
    controller = new AbortController();
    state.update((s) => ({ ...s, isLoading: true, error: null }));

    try {
      const result = await asyncFn();
      if (!controller.signal.aborted) {
        state.set({ data: result, isLoading: false, error: null });
      }
    } catch (err) {
      if (controller.signal.name !== 'AbortError') {
        state.update((s) => ({ ...s, isLoading: false, error: normalizeError(err) }));
      }
    }
  };

  return { state, execute, cancel: () => controller?.abort() };
}

Edge Case: Reactivity system microtask scheduling conflicts can cause duplicate state updates. Framework-specific lifecycle mismatches during Hot Module Replacement (HMR) may trigger double error dispatches if cleanup routines aren’t strictly enforced. Pitfall: Assuming identical update batching across frameworks leads to race conditions. Always verify store subscription cleanup during HMR cycles.

5. Persistence Strategies & Session State Preservation

Crash-resilient applications require deferred state hydration and recovery queues. When async operations fail, preserving form inputs, scroll positions, and partial payloads prevents data loss during network interruptions or page reloads.

// Deferred Retry Queue with Exponential Backoff
interface RetryTask {
  id: string;
  payload: unknown;
  attempt: number;
  maxAttempts: number;
  execute: () => Promise<void>;
  nextRetryAt: number;
}

const retryQueue: Map<string, RetryTask> = new Map();

export const scheduleRetry = (task: Omit<RetryTask, 'nextRetryAt'>) => {
  const backoff = Math.min(1000 * Math.pow(2, task.attempt), 30000);
  retryQueue.set(task.id, { ...task, nextRetryAt: Date.now() + backoff });
};

export const processRetryQueue = async () => {
  const now = Date.now();
  for (const [id, task] of retryQueue.entries()) {
    if (now >= task.nextRetryAt) {
      try {
        await task.execute();
        retryQueue.delete(id);
      } catch (err) {
        if (task.attempt >= task.maxAttempts) {
          retryQueue.delete(id);
          // Fallback to persistent storage
          persistToIndexedDB(`failed_task_${id}`, task.payload);
        } else {
          scheduleRetry({ ...task, attempt: task.attempt + 1 });
        }
      }
    }
  }
};

// Storage middleware with quota guard
export const persistToIndexedDB = async (key: string, value: unknown) => {
  try {
    const serialized = JSON.stringify(value);
    if (serialized.length > 5 * 1024 * 1024) throw new Error('QUOTA_EXCEEDED');
    // Implement actual IndexedDB transaction here
  } catch (err) {
    console.warn('Persistence failed:', err);
  }
};

Edge Case: Storage quota exceeded during large payload caching requires graceful fallback to in-memory queues. Corrupted state deserialization on recovery must be validated via schema checks before hydration. Pitfall: Synchronous localStorage I/O blocks render cycles. Never persist sensitive data without client-side encryption or explicit user consent.

6. Telemetry Hooks & Observability Integration

Structured logging and performance metrics must be captured without impacting main thread responsiveness. A side-effect telemetry hook enriches error payloads with stack traces, network status, and session identifiers, then dispatches them via non-blocking APIs.

export function useErrorTelemetry(error: AsyncErrorPayload | null) {
  useEffect(() => {
    if (!error) return;

    const fingerprint = generateFingerprint(error);
    const payload = {
      ...error,
      fingerprint,
      userAgent: navigator.userAgent,
      networkStatus: navigator.onLine ? 'online' : 'offline',
      sessionId: window.sessionStorage.getItem('session_id') ?? 'unknown',
    };

    // Beacon API for crash reporting (non-blocking)
    const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
    navigator.sendBeacon('/api/telemetry/errors', blob);
  }, [error]);
}

// Deterministic fingerprinting for deduplication
function generateFingerprint(err: AsyncErrorPayload): string {
  const raw = `${err.code}:${err.message.split('\n')[0]}:${err.context?.endpoint ?? ''}`;
  let hash = 0;
  for (let i = 0; i < raw.length; i++) {
    const char = raw.charCodeAt(i);
    hash = (hash << 5) - hash + char;
    hash |= 0;
  }
  return Math.abs(hash).toString(36);
}

Edge Case: Network unavailability during telemetry dispatch requires local queuing. Analytics providers often rate-limit high-frequency error bursts; implement client-side throttling. Pitfall: Circular error references cause infinite logging loops. Avoid synchronous telemetry serialization during render phases to prevent overhead.

7. Graceful Degradation & UX Fallback Patterns

Progressive enhancement requires mapping error states to skeleton loaders, inline retry actions, and offline-capable UI shells. Fallbacks must degrade gracefully under constrained network conditions while maintaining strict accessibility compliance.

import { useAsyncCatch } from './useAsyncCatch';

interface SeverityFallbackProps {
  error: AsyncErrorPayload | null;
  onRetry: () => void;
  isLoading: boolean;
}

export const AsyncFallback = ({ error, onRetry, isLoading }: SeverityFallbackProps) => {
  if (isLoading)
    return (
      <div role="status" aria-live="polite">
        Loading content...
      </div>
    );
  if (!error) return null;

  const isCritical = error.severity === 'critical';
  const isTransient = error.code.startsWith('HTTP_4') || error.code === 'NETWORK_TIMEOUT';

  return (
    <div role="alert" aria-live="assertive" className="error-container">
      <p>
        {isCritical
          ? 'Service unavailable. Please try again later.'
          : 'Connection interrupted.'}
      </p>
      {isTransient && (
        <button onClick={onRetry} aria-label="Retry loading content">
          Retry Now
        </button>
      )}
    </div>
  );
};

// Optimistic UI rollback handler
export const useOptimisticRollback = <T,>(initialState: T) => {
  const [state, setState] = useState(initialState);
  const rollbackRef = useRef<T>(initialState);

  const applyOptimistic = (update: T) => {
    rollbackRef.current = state;
    setState(update);
  };

  const rollback = () => setState(rollbackRef.current);
  return { state, applyOptimistic, rollback };
};

Edge Case: Fallback UI triggering during transient network blips causes flickering. Implement debounce thresholds before rendering error states. Pitfall: Masking critical errors with non-actionable UI states frustrates users. Infinite retry loops without user intervention drain battery and bandwidth.

8. Edge Cases & Implementation Pitfalls

Systematic auditing of async error architectures reveals recurring failure modes: memory leaks from unresolved promises, unmounted component updates, and third-party SDK interference. Mitigation requires strict unmount guards and isolated boundary testing.

// Unmount guard utility
export const createUnmountGuard = () => {
  let mounted = true;
  const check = () => mounted;
  const cleanup = () => {
    mounted = false;
  };
  return { check, cleanup };
};

// Isolation test pattern for Jest/Playwright
export const testAsyncBoundaryIsolation = async () => {
  const mockFn = jest.fn().mockRejectedValue(new Error('SDK_FAILURE'));
  const { result } = renderHook(() => useAsyncCatch(mockFn));

  await act(async () => {
    await result.current.execute();
  });

  expect(result.current.error).toBeDefined();
  expect(result.current.error?.code).toBe('UNKNOWN_ERROR');
  // Verify no state updates occur after unmount simulation
};

Edge Case: Third-party script promise rejections leak into app state if global handlers aren’t namespaced. WebVitals metric collection during error states can skew performance data. Pitfall: Global error handlers overriding local hook context destroys granular recovery capabilities. Inadequate test coverage for promise rejection timing leads to flaky CI pipelines.

9. Frequently Asked Questions

How do custom hooks intercept errors that bypass traditional try/catch blocks? By wrapping async operations in a centralized executor that normalizes promise rejections into synchronous state updates, ensuring consistent error routing regardless of origin.

Can async error hooks coexist with framework-level error boundaries? Yes. Hooks manage component-level async state, while boundaries handle synchronous render failures. Proper context bridging ensures both operate without conflict.

What is the recommended strategy for preventing memory leaks during unmounts? Implement AbortController cancellation, unmount guards in cleanup functions, and ensure all pending promises are resolved or explicitly ignored before state updates.

How should QA teams simulate async failures for regression testing? Use network interception tools, promise rejection mocks, and deterministic error injection utilities to validate fallback rendering, telemetry dispatch, and state recovery.

10. Implementation Checklist & Validation

Before deploying async error catching architectures, validate against strict engineering and QA criteria:

  • Type Safety: All async payloads conform to AsyncErrorPayload interface. No any types in error propagation chains.
  • Bundle Constraints: Hook imports are tree-shakeable. Telemetry and retry utilities are dynamically imported only when needed.
  • Error Simulation: Jest/Playwright suites cover network timeouts, 4xx/5xx responses, and unhandled promise rejections.
  • Accessibility: All fallback UIs include role="alert" or aria-live attributes. Screen reader announcements do not interrupt critical user flows.
  • Performance: Main thread blocking < 50ms during error serialization. Telemetry dispatch uses navigator.sendBeacon or Web Workers.
  • QA Sign-off: Regression tests pass under throttled network conditions. State persistence survives full page reloads without data corruption.

Establish clear pass/fail metrics: zero unhandled promise rejections in production logs, < 2% fallback UI flicker rate, and 100% telemetry delivery rate under stable network conditions.