Files
BanGUI/frontend/src/hooks/useFetchData.ts
Lukas 69e1726045 Refactor data fetching hooks, add page size lint test
- Simplify useFetchData: remove unused URL building logic
- Add usePolledData initial implementation
- Add router page_size param validation test
- Update API reference docs
- Clean up tasks doc
2026-05-04 06:48:24 +02:00

244 lines
9.5 KiB
TypeScript

/**
* 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<TResponse> {
/** This instance's abort signal. */
signal: AbortSignal;
/** Callbacks registered by this subscriber. */
onSetData?: (response: TResponse) => void;
onSuccess?: (response: TResponse) => void;
}
interface InFlightRequest<TResponse> {
promise: Promise<TResponse>;
controller: AbortController;
/** Map of subscriberId -> Subscriber. Cleared when subscriber aborts. */
subscribers: Map<string, Subscriber<TResponse>>;
/** True when initiator has cleaned up but subscribers remain. */
initiatorDone: boolean;
}
const inFlightRequests = new Map<string, InFlightRequest<unknown>>();
/** Visible for testing only. Clears all in-flight request state. */
export const _resetInFlightRequests = (): void => {
inFlightRequests.clear();
};
export interface UseFetchDataOptions<TResponse, TData> {
/** Async function that accepts an AbortSignal for cancellation. */
fetcher: (signal: AbortSignal) => Promise<TResponse>;
/** 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<TData> {
/** 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<TData | null>;
}
/**
* 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<TResponse, TData>(
options: UseFetchDataOptions<TResponse, TData>,
): UseFetchDataResult<TData> {
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 subscriberAbortRef = useRef<AbortController | null>(null);
/** Unique ID for this instance, used to track its subscription to deduplicated requests. */
const subscriberIdRef = useRef<string | null>(null);
const refresh = useCallback(async (): Promise<TData | null> => {
// 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<TResponse>;
// 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<TResponse> = {
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<TResponse>,
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 };
}