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:
2026-04-25 19:08:26 +02:00
parent b44b72053a
commit 8d30a81346
7 changed files with 244 additions and 281 deletions

View File

@@ -1,30 +1,3 @@
### T-11 · Repositories injected as module references via `cast()` — structural type-safety gap
**Where found:** `backend/app/dependencies.py``get_session_repo()`, `get_blocklist_repo()`, `get_settings_repo()`, `get_import_log_repo()`, `get_history_archive_repo()`, `get_geo_cache_repo()`, `get_fail2ban_db_repo()` all return the module itself cast to the Protocol type.
**Why this is needed:** The `cast()` call is a signal that the type system is being overridden. Modules pass Protocol structural checks only because their top-level `async def` functions happen to match the Protocol method signatures. This is fragile — a module rename, a function rename, or an added required parameter will silently pass mypy but fail at runtime.
**Goal:** Repository modules become proper singleton instances, or the dependency providers are acknowledged as module-adapters with explicit documentation.
**What to do (option A — correct):**
1. Convert each repository module's functions into a class with the same method signatures.
2. Instantiate singletons at startup and store on `app.state` or as module-level instances.
3. Update dependency providers to return the instance without `cast()`.
**What to do (option B — minimal):**
1. Document in each `get_*_repo` provider why the module-as-Protocol pattern is intentional.
2. Add a CI check (or mypy plugin) that validates structural compatibility doesn't silently break.
**Possible traps and issues:**
- Option A is a significant refactor affecting all repository call sites.
- Option B risks the pattern silently breaking in future.
**Docs changes needed:** `Docs/Backend-Development.md` — document repository injection pattern and why it works.
**Doc references:** `Docs/Backend-Development.md`, `backend/app/repositories/protocols.py`
---
### T-12 · Apply `useListData` consistently across all data-fetching hooks ### T-12 · Apply `useListData` consistently across all data-fetching hooks
**Where found:** `frontend/src/hooks/useJailList.ts`, `useJailDetail.ts`, `useServerStatus.ts`, `useBanTrend.ts`, `useDashboardCountryData.ts` — all re-implement abort-controller / loading / error state manually. `useListData.ts` exists and is used by `useBlocklists`, `useJailConfigs`, `useActionList`, `useFilterList`. **Where found:** `frontend/src/hooks/useJailList.ts`, `useJailDetail.ts`, `useServerStatus.ts`, `useBanTrend.ts`, `useDashboardCountryData.ts` — all re-implement abort-controller / loading / error state manually. `useListData.ts` exists and is used by `useBlocklists`, `useJailConfigs`, `useActionList`, `useFilterList`.

View File

@@ -5,14 +5,10 @@
* Re-fetches automatically when `timeRange` or `origin` changes. * Re-fetches automatically when `timeRange` or `origin` changes.
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useState } from "react";
import { fetchBanTrend } from "../api/dashboard"; import { fetchBanTrend } from "../api/dashboard";
import { handleFetchError } from "../utils/fetchError"; import { useListData } from "./useListData";
import type { BanTrendBucket, BanOriginFilter, TimeRange } from "../types/ban"; import type { BanTrendBucket, BanOriginFilter, TimeRange, BanTrendResponse } from "../types/ban";
// ---------------------------------------------------------------------------
// Return type
// ---------------------------------------------------------------------------
/** Return value shape for {@link useBanTrend}. */ /** Return value shape for {@link useBanTrend}. */
export interface UseBanTrendResult { export interface UseBanTrendResult {
@@ -28,10 +24,6 @@ export interface UseBanTrendResult {
reload: () => void; reload: () => void;
} }
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/** /**
* Fetch and expose ban trend data for the `BanTrendChart` component. * Fetch and expose ban trend data for the `BanTrendChart` component.
* *
@@ -44,44 +36,26 @@ export function useBanTrend(
origin: BanOriginFilter, origin: BanOriginFilter,
source: "fail2ban" | "archive" = "fail2ban", source: "fail2ban" | "archive" = "fail2ban",
): UseBanTrendResult { ): UseBanTrendResult {
const [buckets, setBuckets] = useState<BanTrendBucket[]>([]);
const [bucketSize, setBucketSize] = useState<string>("1h"); const [bucketSize, setBucketSize] = useState<string>("1h");
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null); const fetcher = useCallback(
(signal: AbortSignal) =>
fetchBanTrend(timeRange, origin, source, signal),
[timeRange, origin, source],
);
const load = useCallback((): void => { const selector = useCallback((response: BanTrendResponse) => response.buckets, []);
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true); const onSuccess = useCallback((response: BanTrendResponse) => {
setError(null); setBucketSize(response.bucket_size);
}, []);
fetchBanTrend(timeRange, origin, source, controller.signal) const { items: buckets, loading, error, refresh } = useListData<BanTrendResponse, BanTrendBucket>({
.then((data) => { fetcher,
if (controller.signal.aborted) return; selector,
setBuckets(data.buckets); errorMessage: "Failed to fetch trend data",
setBucketSize(data.bucket_size); onSuccess,
}) });
.catch((err: unknown) => {
if (controller.signal.aborted) return;
handleFetchError(err, setError, "Failed to fetch trend data");
})
.finally(() => {
if (!controller.signal.aborted) {
setLoading(false);
}
});
}, [timeRange, origin, source]);
useEffect(() => { return { buckets, bucketSize, loading, error, reload: refresh };
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
return { buckets, bucketSize, loading, error, reload: load };
} }

View File

@@ -7,14 +7,11 @@
* Re-fetches automatically when `timeRange` or `origin` changes. * Re-fetches automatically when `timeRange` or `origin` changes.
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useState } from "react";
import { fetchBansByCountry } from "../api/map"; import { fetchBansByCountry } from "../api/map";
import { handleFetchError } from "../utils/fetchError"; import { useListData } from "./useListData";
import type { DashboardBanItem, BanOriginFilter, TimeRange } from "../types/ban"; import type { BanOriginFilter, TimeRange } from "../types/ban";
import type { BansByCountryResponse, MapBanItem } from "../types/map";
// ---------------------------------------------------------------------------
// Return type
// ---------------------------------------------------------------------------
/** Return value shape for {@link useDashboardCountryData}. */ /** Return value shape for {@link useDashboardCountryData}. */
export interface UseDashboardCountryDataResult { export interface UseDashboardCountryDataResult {
@@ -23,7 +20,7 @@ export interface UseDashboardCountryDataResult {
/** ISO alpha-2 country code → human-readable country name. */ /** ISO alpha-2 country code → human-readable country name. */
countryNames: Record<string, string>; countryNames: Record<string, string>;
/** All ban records in the selected window. */ /** All ban records in the selected window. */
bans: DashboardBanItem[]; bans: MapBanItem[];
/** Total ban count in the window. */ /** Total ban count in the window. */
total: number; total: number;
/** True while a fetch is in flight. */ /** True while a fetch is in flight. */
@@ -34,10 +31,6 @@ export interface UseDashboardCountryDataResult {
reload: () => void; reload: () => void;
} }
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/** /**
* Fetch and expose ban-by-country data for dashboard charts. * Fetch and expose ban-by-country data for dashboard charts.
* *
@@ -52,47 +45,28 @@ export function useDashboardCountryData(
): UseDashboardCountryDataResult { ): UseDashboardCountryDataResult {
const [countries, setCountries] = useState<Record<string, number>>({}); const [countries, setCountries] = useState<Record<string, number>>({});
const [countryNames, setCountryNames] = useState<Record<string, string>>({}); const [countryNames, setCountryNames] = useState<Record<string, string>>({});
const [bans, setBans] = useState<DashboardBanItem[]>([]);
const [total, setTotal] = useState<number>(0); const [total, setTotal] = useState<number>(0);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null); const fetcher = useCallback(
(signal: AbortSignal) =>
fetchBansByCountry(timeRange, origin, source, undefined, signal),
[timeRange, origin, source],
);
const load = useCallback((): void => { const selector = useCallback((response: BansByCountryResponse) => response.bans, []);
// Abort any in-flight request.
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true); const onSuccess = useCallback((response: BansByCountryResponse) => {
setError(null); setCountries(response.countries);
setCountryNames(response.country_names);
setTotal(response.total);
}, []);
fetchBansByCountry(timeRange, origin, source, undefined, controller.signal) const { items: bans, loading, error, refresh } = useListData<BansByCountryResponse, MapBanItem>({
.then((data) => { fetcher,
if (controller.signal.aborted) return; selector,
setCountries(data.countries); errorMessage: "Failed to fetch dashboard country data",
setCountryNames(data.country_names); onSuccess,
setBans(data.bans); });
setTotal(data.total);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
handleFetchError(err, setError, "Failed to fetch dashboard country data");
})
.finally(() => {
if (!controller.signal.aborted) {
setLoading(false);
}
});
}, [timeRange, origin, source]);
useEffect(() => { return { countries, countryNames, bans, total, loading, error, reload: refresh };
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
return { countries, countryNames, bans, total, loading, error, reload: load };
} }

View File

@@ -5,13 +5,10 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchHistory, fetchIpHistory } from "../api/history"; import { fetchHistory, fetchIpHistory } from "../api/history";
import { handleFetchError } from "../utils/fetchError"; import { handleFetchError } from "../utils/fetchError";
import type { HistoryBanItem, IpDetailResponse } from "../types/history"; import { useListData } from "./useListData";
import type { HistoryBanItem, IpDetailResponse, HistoryListResponse } from "../types/history";
import type { BanOriginFilter, TimeRange } from "../types/ban"; import type { BanOriginFilter, TimeRange } from "../types/ban";
// ---------------------------------------------------------------------------
// useHistory — paginated list
// ---------------------------------------------------------------------------
export interface UseHistoryResult { export interface UseHistoryResult {
items: HistoryBanItem[]; items: HistoryBanItem[];
total: number; total: number;
@@ -43,50 +40,38 @@ export function useHistory(
ip?: string, ip?: string,
source: "fail2ban" | "archive" = "archive", source: "fail2ban" | "archive" = "archive",
): UseHistoryResult { ): UseHistoryResult {
const [items, setItems] = useState<HistoryBanItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(page); const [currentPage, setCurrentPage] = useState(page);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback((): void => { const fetcher = useCallback(
abortRef.current?.abort(); (signal: AbortSignal) =>
abortRef.current = new AbortController(); fetchHistory(
setLoading(true); {
setError(null); page: currentPage,
page_size: pageSize,
range,
origin,
jail,
ip,
source,
},
signal,
),
[currentPage, pageSize, range, origin, jail, ip, source],
);
fetchHistory( const selector = useCallback((response: HistoryListResponse) => response.items, []);
{
page: currentPage,
page_size: pageSize,
range,
origin,
jail,
ip,
source,
},
abortRef.current.signal,
)
.then((resp) => {
setItems(resp.items);
setTotal(resp.total);
})
.catch((err: unknown) => {
handleFetchError(err, setError, "Failed to fetch history");
})
.finally((): void => {
setLoading(false);
});
}, [currentPage, pageSize, range, origin, jail, ip, source]);
const onSuccess = useCallback((response: HistoryListResponse) => {
setTotal(response.total);
}, []);
useEffect((): (() => void) => { const { items, loading, error, refresh } = useListData<HistoryListResponse, HistoryBanItem>({
load(); fetcher,
return (): void => { selector,
abortRef.current?.abort(); errorMessage: "Failed to fetch history",
}; onSuccess,
}, [load]); });
return { return {
items, items,
@@ -95,7 +80,7 @@ export function useHistory(
loading, loading,
error, error,
setPage: setCurrentPage, setPage: setCurrentPage,
refresh: load, refresh,
}; };
} }
@@ -110,6 +95,12 @@ export interface UseIpHistoryResult {
refresh: () => void; refresh: () => void;
} }
/**
* Fetch and manage IP detail history.
*
* @param ip - IP address to fetch history for
* @returns IP detail response, loading state, error, and refresh callback
*/
export function useIpHistory(ip: string): UseIpHistoryResult { export function useIpHistory(ip: string): UseIpHistoryResult {
const [detail, setDetail] = useState<IpDetailResponse | null>(null); const [detail, setDetail] = useState<IpDetailResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -124,13 +115,19 @@ export function useIpHistory(ip: string): UseIpHistoryResult {
fetchIpHistory(ip, abortRef.current.signal) fetchIpHistory(ip, abortRef.current.signal)
.then((resp) => { .then((resp) => {
setDetail(resp); if (!abortRef.current?.signal.aborted) {
setDetail(resp);
}
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
handleFetchError(err, setError, "Failed to fetch IP history"); if (!abortRef.current?.signal.aborted) {
handleFetchError(err, setError, "Failed to fetch IP history");
}
}) })
.finally((): void => { .finally((): void => {
setLoading(false); if (!abortRef.current?.signal.aborted) {
setLoading(false);
}
}); });
}, [ip]); }, [ip]);

View File

@@ -2,7 +2,7 @@
* React hook for loading and controlling the jail overview list. * React hook for loading and controlling the jail overview list.
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useState } from "react";
import { import {
fetchJails, fetchJails,
reloadAllJails, reloadAllJails,
@@ -11,8 +11,8 @@ import {
startJail, startJail,
stopJail, stopJail,
} from "../api/jails"; } from "../api/jails";
import { handleFetchError } from "../utils/fetchError"; import { useListData } from "./useListData";
import type { JailSummary } from "../types/jail"; import type { JailSummary, JailListResponse } from "../types/jail";
export interface UseJailsResult { export interface UseJailsResult {
jails: JailSummary[]; jails: JailSummary[];
@@ -31,83 +31,64 @@ export interface UseJailsResult {
* Fetch and manage the jail overview list. * Fetch and manage the jail overview list.
*/ */
export function useJails(): UseJailsResult { export function useJails(): UseJailsResult {
const [jails, setJails] = useState<JailSummary[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback(() => { const fetcher = useCallback(
abortRef.current?.abort(); (signal: AbortSignal) => fetchJails(signal),
const ctrl = new AbortController(); [],
abortRef.current = ctrl; );
setLoading(true);
setError(null);
fetchJails(ctrl.signal) const selector = useCallback((response: JailListResponse) => response.jails, []);
.then((res) => {
if (!ctrl.signal.aborted) { const onSuccess = useCallback((response: JailListResponse) => {
setJails(res.jails); setTotal(response.total);
setTotal(res.total);
}
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
handleFetchError(err, setError, "Failed to load jails");
}
})
.finally(() => {
if (!ctrl.signal.aborted) {
setLoading(false);
}
});
}, []); }, []);
useEffect(() => { const { items: jails, loading, error, refresh } = useListData<JailListResponse, JailSummary>({
load(); fetcher,
return (): void => { selector,
abortRef.current?.abort(); errorMessage: "Failed to load jails",
}; onSuccess,
}, [load]); });
const startJailMemo = useCallback( const startJailMemo = useCallback(
async (name: string): Promise<void> => { async (name: string): Promise<void> => {
await startJail(name); await startJail(name);
load(); refresh();
}, },
[load], [refresh],
); );
const stopJailMemo = useCallback( const stopJailMemo = useCallback(
async (name: string): Promise<void> => { async (name: string): Promise<void> => {
await stopJail(name); await stopJail(name);
load(); refresh();
}, },
[load], [refresh],
); );
const reloadJailMemo = useCallback( const reloadJailMemo = useCallback(
async (name: string): Promise<void> => { async (name: string): Promise<void> => {
await reloadJail(name); await reloadJail(name);
load(); refresh();
}, },
[load], [refresh],
); );
const setIdleMemo = useCallback( const setIdleMemo = useCallback(
(name: string, on: boolean): Promise<void> => (name: string, on: boolean): Promise<void> =>
setJailIdle(name, on).then(() => { setJailIdle(name, on).then(() => {
load(); refresh();
}), }),
[load], [refresh],
); );
const reloadAllMemo = useCallback( const reloadAllMemo = useCallback(
(): Promise<void> => (): Promise<void> =>
reloadAllJails().then(() => { reloadAllJails().then(() => {
load(); refresh();
}), }),
[load], [refresh],
); );
return { return {
@@ -115,7 +96,7 @@ export function useJails(): UseJailsResult {
total, total,
loading, loading,
error, error,
refresh: load, refresh,
startJail: startJailMemo, startJail: startJailMemo,
stopJail: stopJailMemo, stopJail: stopJailMemo,
setIdle: setIdleMemo, setIdle: setIdleMemo,

View 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 };
}

View File

@@ -6,10 +6,10 @@
* status is always fresh when the user returns to the tab. * status is always fresh when the user returns to the tab.
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback } from "react";
import { fetchServerStatus } from "../api/dashboard"; import { fetchServerStatus } from "../api/dashboard";
import { handleFetchError } from "../utils/fetchError"; import { usePolledData } from "./usePolledData";
import type { ServerStatus } from "../types/server"; import type { ServerStatus, ServerStatusResponse } from "../types/server";
/** How often to poll the status endpoint (milliseconds). */ /** How often to poll the status endpoint (milliseconds). */
const POLL_INTERVAL_MS = 30_000; const POLL_INTERVAL_MS = 30_000;
@@ -32,71 +32,20 @@ export interface UseServerStatusResult {
* @returns Current status, loading state, error, and a `refresh` callback. * @returns Current status, loading state, error, and a `refresh` callback.
*/ */
export function useServerStatus(): UseServerStatusResult { export function useServerStatus(): UseServerStatusResult {
const [status, setStatus] = useState<ServerStatus | null>(null); const fetcher = useCallback(
const [loading, setLoading] = useState<boolean>(true); (signal: AbortSignal) => fetchServerStatus(signal),
const [error, setError] = useState<string | null>(null); [],
);
// Use a ref so the fetch function identity is stable. const selector = useCallback((response: ServerStatusResponse) => response.status, []);
const fetchRef = useRef<() => Promise<void>>(async () => Promise.resolve());
const abortRef = useRef<AbortController | null>(null); const { data: status, loading, error, refresh } = usePolledData<ServerStatusResponse, ServerStatus>({
fetcher,
const doFetch = useCallback(async (): Promise<void> => { selector,
abortRef.current?.abort(); errorMessage: "Failed to fetch server status",
const controller = new AbortController(); pollInterval: POLL_INTERVAL_MS,
abortRef.current = controller; refetchOnWindowFocus: true,
});
setLoading(true);
try {
const data = await fetchServerStatus(controller.signal);
if (controller.signal.aborted) {
return;
}
setStatus(data.status);
setError(null);
} catch (err: unknown) {
if (controller.signal.aborted) {
return;
}
handleFetchError(err, setError, "Failed to fetch server status");
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, []);
fetchRef.current = doFetch;
// Initial fetch + polling interval.
useEffect((): (() => void) => {
void doFetch().catch((): void => undefined);
const id = setInterval((): void => {
void fetchRef.current().catch((): void => undefined);
}, POLL_INTERVAL_MS);
return (): void => { clearInterval(id); };
}, [doFetch]);
// Refetch on window focus.
useEffect(() => {
const onFocus = (): void => {
void fetchRef.current();
};
window.addEventListener("focus", onFocus);
return (): void => { window.removeEventListener("focus", onFocus); };
}, []);
useEffect(() => {
return (): void => {
abortRef.current?.abort();
};
}, []);
const refresh = useCallback((): void => {
void doFetch().catch((): void => undefined);
}, [doFetch]);
return { status, loading, error, refresh }; return { status, loading, error, refresh };
} }