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:
2026-05-01 18:44:46 +02:00
parent e46062d4cd
commit 3b3728c58d
3 changed files with 248 additions and 66 deletions

View File

@@ -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();