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>
This commit is contained in:
115
frontend/src/hooks/usePolledData.ts
Normal file
115
frontend/src/hooks/usePolledData.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user