Files
BanGUI/frontend/src/hooks/usePolledData.ts
Lukas d10145e5d6 refactor(frontend): extract shared fetch lifecycle into useFetchData base hook
Eliminates ~100 lines of duplicated code across useListData and usePolledData
by creating a composable base hook that handles:
- Abort controller lifecycle and cancellation
- Loading/error state management
- Fetch error handling
- Unmount cleanup

Changes:
- Create hooks/useFetchData.ts with base fetch lifecycle (no effects on consumers)
- Refactor useListData to compose useFetchData, returns items array by default
- Refactor usePolledData to compose useFetchData, adds polling and focus-refetch
- Add comprehensive tests for useFetchData base hook
- Document hook architecture and composition pattern in Web-Development.md

Result: Both hooks now use shared primitives, reducing maintenance burden
and ensuring consistent cancellation/error handling across all data fetches.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 09:23:34 +02:00

111 lines
3.4 KiB
TypeScript

/**
* Generic hook for loading and polling single-item data from an API endpoint.
*
* Composes useFetchData and adds polling and window-focus refetch semantics
* for non-list endpoints that need periodic updates.
*/
import { useEffect, useRef } from "react";
import { useFetchData } from "./useFetchData";
import type { FetchError } from "../types/api";
export interface UsePolledDataOptions<TResponse, TData> {
/** Async function that accepts an AbortSignal for cancellation. */
fetcher: (signal: AbortSignal) => Promise<TResponse>;
/** Synchronous selector to extract data from 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. Defaults to null. */
initialData?: TData;
/** Polling interval in milliseconds. If unset, no periodic polling. */
pollInterval?: number;
/** If true, automatically refetch when browser window regains focus. Defaults to true. */
refetchOnWindowFocus?: boolean;
}
export interface UsePolledDataResult<TData> {
/** The extracted data from the most recent successful fetch, or null if not yet fetched. */
data: TData | null;
/** 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. */
refresh: () => void;
}
/**
* Load a single-item response and expose refresh semantics with polling support.
*
* Provides typed error handling through the `error` property, which is either
* `null` or a discriminated `FetchError` union. Use the error's `type` field
* to determine how to handle it:
*
* - `"api_error"`: Server returned HTTP error (check `status` for 401/403/50x)
* - `"network_error"`: Network, DNS, or JSON parse failure
* - `"abort_error"`: Request was cancelled (typically silently ignored by hook)
*
* @param options - Configuration options
* @returns Data, loading state, typed error, and refresh callback
*/
export function usePolledData<TResponse, TData>(
options: UsePolledDataOptions<TResponse, TData>,
): UsePolledDataResult<TData> {
const {
fetcher,
selector,
errorMessage,
onSuccess,
initialData,
pollInterval,
refetchOnWindowFocus = true,
} = options;
const { data, loading, error, refresh } = useFetchData({
fetcher,
selector,
errorMessage,
onSuccess,
initialData,
});
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
}, [refresh]);
// Polling effect: set up interval if pollInterval is provided
useEffect(() => {
if (!pollInterval) {
return;
}
const id = setInterval((): void => {
refreshRef.current();
}, pollInterval);
return (): void => {
clearInterval(id);
};
}, [pollInterval]);
// Window focus: optional refetch on regain focus
useEffect(() => {
if (!refetchOnWindowFocus) return;
const onFocus = (): void => {
refreshRef.current();
};
window.addEventListener("focus", onFocus);
return (): void => {
window.removeEventListener("focus", onFocus);
};
}, [refetchOnWindowFocus]);
return { data: data ?? null, loading, error, refresh };
}