/** * Composable base hook for fetch lifecycle, cancellation, and error handling. * * Provides common primitives for data-fetching hooks: abort controller management, * loading/error state, and a refresh callback with cancellation safety. * * This is an internal hook meant to be composed by higher-level hooks like * useListData and usePolledData. Direct usage is discouraged — instead, * create a domain-specific hook that wraps this base and adds your specific * requirements (e.g., polling, windowed effects, derived state). * * When a `requestKey` is provided, multiple hook instances with the same key * will deduplicate in-flight requests, ensuring only one fetch happens at a time * for that data. This prevents wasted bandwidth and race conditions. */ import { useCallback, useEffect, useRef, useState } from "react"; import { handleFetchError } from "../utils/fetchError"; import type { FetchError } from "../types/api"; /** * Module-level cache for in-flight requests. * Maps requestKey to { promise, controller, subscribers } to enable deduplication * across multiple hook instances. */ interface Subscriber { /** This instance's abort signal. */ signal: AbortSignal; /** Callbacks registered by this subscriber. */ onSetData?: (response: TResponse) => void; onSuccess?: (response: TResponse) => void; } interface InFlightRequest { promise: Promise; controller: AbortController; /** Map of subscriberId -> Subscriber. Cleared when subscriber aborts. */ subscribers: Map>; /** True when initiator has cleaned up but subscribers remain. */ initiatorDone: boolean; } const inFlightRequests = new Map>(); /** Visible for testing only. Clears all in-flight request state. */ export const _resetInFlightRequests = (): void => { inFlightRequests.clear(); }; export interface UseFetchDataOptions { /** Async function that accepts an AbortSignal for cancellation. */ fetcher: (signal: AbortSignal) => Promise; /** Synchronous selector to extract domain data from the response. */ selector: (response: TResponse) => TData; /** Human-readable error message used as fallback if fetch fails. */ errorMessage: string; /** Optional callback invoked after successful fetch with full response. */ onSuccess?: (response: TResponse) => void; /** Initial data value. If undefined, data starts as undefined until first fetch. */ initialData?: TData; /** * Optional unique key for request deduplication. * When provided, multiple hook instances with the same key will share * in-flight requests, preventing duplicate API calls. */ requestKey?: string; } export interface UseFetchDataResult { /** The extracted data from the most recent successful fetch. */ data: TData | undefined; /** True while a fetch is in-flight, false when complete. */ loading: boolean; /** Typed error or null. Check `error?.type` to handle specific failure modes. */ error: FetchError | null; /** Trigger a fresh fetch. Cancels any in-flight request first. Returns null if aborted, otherwise the selected data. */ refresh: () => Promise; } /** * Generic base hook that manages the fetch lifecycle for a single resource. * * Handles abort controller management, error handling, and refresh semantics. * Automatically cancels in-flight requests on component unmount. * * When `requestKey` is provided, deduplicates in-flight requests across * multiple hook instances. If a request is already in-flight for that key, * the hook reuses the existing promise instead of making a duplicate call. * This prevents wasted bandwidth and race conditions when multiple components * fetch the same data simultaneously or when the same component calls refresh rapidly. * * Prefer composing this hook via higher-level hooks (useListData, usePolledData) * rather than using directly. * * @param options - Configuration: fetcher, selector, error message, and optional callbacks * @returns Data, loading state, typed error, and refresh callback */ export function useFetchData( options: UseFetchDataOptions, ): UseFetchDataResult { const { fetcher, selector, errorMessage, onSuccess, initialData, requestKey } = options; const [data, setData] = useState(initialData); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const abortRef = useRef(null); const localControllerRef = useRef(null); const subscriberAbortRef = useRef(null); /** Unique ID for this instance, used to track its subscription to deduplicated requests. */ const subscriberIdRef = useRef(null); const refresh = useCallback(async (): Promise => { // Abort any previous request from this hook instance abortRef.current?.abort(); localControllerRef.current = new AbortController(); abortRef.current = localControllerRef.current; // If using request deduplication via requestKey and a request is already in-flight, // subscribe to it instead of launching a duplicate if (requestKey && inFlightRequests.has(requestKey)) { const inFlight = inFlightRequests.get(requestKey)! as InFlightRequest; // Create per-instance abort controller for this subscription. // Do NOT abort previous subscription signals here - each subscriber's signal // only aborts when THAT subscriber unmounts. Aborting old signals would // incorrectly remove other active subscribers from the list. subscriberAbortRef.current = new AbortController(); // Register this instance as a subscriber. // Always generate a new ID - each hook instance must have its own subscriber ID // so that unmounting one subscriber does not remove another subscriber's entry. subscriberIdRef.current = crypto.randomUUID(); const sid = subscriberIdRef.current; const subscription: Subscriber = { signal: subscriberAbortRef.current.signal, onSetData: (response: TResponse) => { setData(selector(response)); if (onSuccess) { onSuccess(response); } }, onSuccess, }; inFlight.subscribers.set(sid, subscription); // When this instance aborts, remove it from the subscriber list. // Note: we do NOT abort the underlying controller here - that belongs to // the initiator and should only be aborted by the initiator itself. const cleanup = (): void => { inFlight.subscribers.delete(sid); }; subscription.signal.addEventListener("abort", cleanup, { once: true }); inFlight.promise .then((response: TResponse) => { // Check whether this subscriber has already aborted before propagating result if (subscription.signal.aborted) return; subscription.onSetData?.(response); }) .catch((err: unknown) => { // Only handle non-abort errors; abort errors are silently ignored if (err instanceof DOMException && err.name === "AbortError") { return; } handleFetchError(err, setError, errorMessage); }) .finally(() => { setLoading(false); }); return null; } setLoading(true); setError(null); const controller = localControllerRef.current; // Raw promise for deduplication storage - stores BEFORE transformation const rawPromise = fetcher(controller.signal).catch((err: unknown) => { if (controller.signal.aborted) throw err; handleFetchError(err, setError, errorMessage); throw err; }); // Store in-flight BEFORE transformation for correct subscriber type if (requestKey) { inFlightRequests.set(requestKey, { promise: rawPromise as Promise, controller, subscribers: new Map(), initiatorDone: false, }); } // Transformed promise for return value - applies selector and returns TData | null const responsePromise = rawPromise .then((response) => { if (controller.signal.aborted) return null; const data = selector(response); setData(data); if (onSuccess) { onSuccess(response); } return data; }) .finally(() => { if (!controller.signal.aborted) { setLoading(false); } if (requestKey) { inFlightRequests.delete(requestKey); } }); return responsePromise; }, [fetcher, selector, errorMessage, onSuccess, requestKey]); useEffect(() => { refresh(); return (): void => { if (subscriberIdRef.current !== null) { // Subscriber cleanup const inFlight = requestKey ? inFlightRequests.get(requestKey) : undefined; subscriberAbortRef.current?.abort(); // If initiator already done and no subscribers left, cancel the request if (inFlight && inFlight.initiatorDone && inFlight.subscribers.size === 0) { inFlight.controller.abort(); } } else { // Initiator cleanup const inFlight = requestKey ? inFlightRequests.get(requestKey) : undefined; // Mark initiator as done so last subscriber knows to cancel if (inFlight) { inFlight.initiatorDone = true; } if (!inFlight || inFlight.subscribers.size === 0) { abortRef.current?.abort(); } } }; }, [refresh]); return { data, loading, error, refresh }; }