feat: implement request deduplication in useFetchData
- Add optional requestKey parameter to UseFetchDataOptions - Implement module-level cache (inFlightRequests) to track in-flight requests - When requestKey is provided, multiple hook instances with same key share in-flight requests - Prevents duplicate API calls when multiple components fetch same data or rapid refresh calls - Cache entries are automatically cleared when response arrives (success or error) - Maintains backward compatibility: without requestKey, behaves as before - Adds comprehensive tests for deduplication scenarios This reduces bandwidth waste and prevents race conditions caused by concurrent requests for identical data. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -8,11 +8,27 @@
|
||||
* 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 } to enable deduplication
|
||||
* across multiple hook instances.
|
||||
*/
|
||||
interface InFlightRequest<TResponse> {
|
||||
promise: Promise<TResponse>;
|
||||
controller: AbortController;
|
||||
}
|
||||
|
||||
const inFlightRequests = new Map<string, InFlightRequest<unknown>>();
|
||||
|
||||
export interface UseFetchDataOptions<TResponse, TData> {
|
||||
/** Async function that accepts an AbortSignal for cancellation. */
|
||||
fetcher: (signal: AbortSignal) => Promise<TResponse>;
|
||||
@@ -24,6 +40,12 @@ export interface UseFetchDataOptions<TResponse, TData> {
|
||||
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<TData> {
|
||||
@@ -43,6 +65,12 @@ export interface UseFetchDataResult<TData> {
|
||||
* 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.
|
||||
*
|
||||
@@ -52,38 +80,79 @@ export interface UseFetchDataResult<TData> {
|
||||
export function useFetchData<TResponse, TData>(
|
||||
options: UseFetchDataOptions<TResponse, TData>,
|
||||
): UseFetchDataResult<TData> {
|
||||
const { fetcher, selector, errorMessage, onSuccess, initialData } = options;
|
||||
const { fetcher, selector, errorMessage, onSuccess, initialData, requestKey } = options;
|
||||
const [data, setData] = useState<TData | undefined>(initialData);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<FetchError | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const localControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const refresh = useCallback((): void => {
|
||||
// If using request deduplication via requestKey and a request is already in-flight,
|
||||
// wait for it to complete instead of launching a duplicate
|
||||
if (requestKey && inFlightRequests.has(requestKey)) {
|
||||
const inFlight = inFlightRequests.get(requestKey)! as InFlightRequest<TResponse>;
|
||||
inFlight.promise
|
||||
.then((response: TResponse) => {
|
||||
setData(selector(response));
|
||||
if (onSuccess) {
|
||||
onSuccess(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;
|
||||
}
|
||||
|
||||
// Abort any previous request from this hook instance
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
localControllerRef.current = new AbortController();
|
||||
abortRef.current = localControllerRef.current;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetcher(controller.signal)
|
||||
const controller = localControllerRef.current;
|
||||
const responsePromise = fetcher(controller.signal)
|
||||
.then((response) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (controller.signal.aborted) return response;
|
||||
setData(selector(response));
|
||||
if (onSuccess) {
|
||||
onSuccess(response);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (controller.signal.aborted) throw err;
|
||||
handleFetchError(err, setError, errorMessage);
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
// Clear cache entry when response arrives
|
||||
if (requestKey) {
|
||||
inFlightRequests.delete(requestKey);
|
||||
}
|
||||
});
|
||||
}, [fetcher, selector, errorMessage, onSuccess]);
|
||||
|
||||
// Store in-flight request for deduplication
|
||||
if (requestKey) {
|
||||
inFlightRequests.set(requestKey, {
|
||||
promise: responsePromise,
|
||||
controller,
|
||||
});
|
||||
}
|
||||
}, [fetcher, selector, errorMessage, onSuccess, requestKey]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
|
||||
Reference in New Issue
Block a user