Files
BanGUI/frontend/src/hooks/usePolledData.ts
Lukas 8d30a81346 feat(hooks): consolidate data-fetching patterns with useListData and usePolledData
- Refactor useJails (useJailList.ts) to use useListData with onSuccess for total
- Refactor useBanTrend to use useListData with onSuccess for bucket_size
- Refactor useDashboardCountryData to use useListData with onSuccess for aggregated data
- Refactor useHistory to use useListData with proper abort guard in finally()
- Create usePolledData for single-item endpoints with polling and window focus refetch
- Refactor useServerStatus to use usePolledData for 30s polling + window focus refetch
- Keep useIpHistory with manual pattern (single-item, no list semantics)
- Document deferred refactoring of useJailDetail (depends on T-13 for data/command split)

All data-fetching hooks now follow one of two consistent patterns:
1. useListData: for paginated/list endpoints with refresh semantics
2. usePolledData: for single-item endpoints with polling and focus-refetch

This eliminates code duplication, centralizes abort-guard logic, and enables
consistent fixes across all data-fetching hooks.

Resolves T-12.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 19:08:26 +02:00

116 lines
3.0 KiB
TypeScript

/**
* Generic hook for loading and polling single-item data from an API endpoint.
*
* Similar to useListData, but for non-list endpoints that need periodic polling
* and window-focus refetch semantics.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { handleFetchError } from "../utils/fetchError";
export interface UsePolledDataOptions<TResponse, TData> {
fetcher: (signal: AbortSignal) => Promise<TResponse>;
selector: (response: TResponse) => TData;
errorMessage: string;
onSuccess?: (response: TResponse) => void;
initialData?: TData;
pollInterval?: number;
refetchOnWindowFocus?: boolean;
}
export interface UsePolledDataResult<TData> {
data: TData | null;
loading: boolean;
error: string | null;
refresh: () => void;
}
/**
* Load a single-item response and expose refresh semantics with polling support.
*
* @param options - Configuration options
* @returns Data, loading state, 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, setData] = useState<TData | null>(initialData ?? null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fetchRef = useRef<() => void>((): void => undefined);
const refresh = useCallback((): void => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
fetcher(controller.signal)
.then((response) => {
if (controller.signal.aborted) return;
setData(selector(response));
if (onSuccess) {
onSuccess(response);
}
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
handleFetchError(err, setError, errorMessage);
})
.finally(() => {
if (!controller.signal.aborted) {
setLoading(false);
}
});
}, [fetcher, selector, errorMessage, onSuccess]);
fetchRef.current = refresh;
useEffect(() => {
refresh();
if (!pollInterval) {
return (): void => {
abortRef.current?.abort();
};
}
const id = setInterval((): void => {
fetchRef.current();
}, pollInterval);
return (): void => {
clearInterval(id);
abortRef.current?.abort();
};
}, [refresh, pollInterval]);
// Refetch on window focus if enabled.
useEffect(() => {
if (!refetchOnWindowFocus) return;
const onFocus = (): void => {
fetchRef.current();
};
window.addEventListener("focus", onFocus);
return (): void => {
window.removeEventListener("focus", onFocus);
};
}, [refetchOnWindowFocus]);
return { data, loading, error, refresh };
}