diff --git a/Docs/Tasks.md b/Docs/Tasks.md index d404f71..50ebe34 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -1,43 +1,4 @@ -## 17) Weak typed error contracts in generic hooks -- Where found: - - [frontend/src/hooks/useListData.ts](frontend/src/hooks/useListData.ts) - - [frontend/src/hooks/usePolledData.ts](frontend/src/hooks/usePolledData.ts) -- Why this is needed: - - Unknown-only error handling weakens actionable UX and diagnostics. -- Goal: - - Standardize API error typing and hook-level error model. -- What to do: - - Introduce discriminated error payloads. - - Map ApiError and network errors consistently. -- Possible traps and issues: - - Type expansion can touch many call sites. -- Docs changes needed: - - Add typed error model examples. -- Doc references: - - [frontend/src/api/client.ts](frontend/src/api/client.ts) - ---- - -## 18) Duplicate polling/list loading behavior across hooks -- Where found: - - [frontend/src/hooks/useListData.ts](frontend/src/hooks/useListData.ts) - - [frontend/src/hooks/usePolledData.ts](frontend/src/hooks/usePolledData.ts) -- Why this is needed: - - Duplication multiplies maintenance bugs. -- Goal: - - Share a composable base for fetch lifecycle, cancellation, and polling. -- What to do: - - Extract shared primitives and keep hook-specific selectors minimal. -- Possible traps and issues: - - Generic abstractions can become too complex. -- Docs changes needed: - - Add hook architecture overview. -- Doc references: - - [Docs/Web-Development.md](Docs/Web-Development.md) - ---- - -## 19) Provider dependency chain is implicit +## 18) Provider dependency chain is implicit - Where found: - [frontend/src/App.tsx](frontend/src/App.tsx) - Why this is needed: @@ -56,7 +17,7 @@ --- -## 20) Loading UX lacks progressive/skeleton states +## 19) Loading UX lacks progressive/skeleton states - Where found: - [frontend/src/pages](frontend/src/pages) - Why this is needed: @@ -74,7 +35,7 @@ --- -## 21) Silent auth error swallow in fetch error utility +## 20) Silent auth error swallow in fetch error utility - Where found: - [frontend/src/utils/fetchError.ts](frontend/src/utils/fetchError.ts) - Why this is needed: @@ -92,7 +53,7 @@ --- -## 22) Magic strings are scattered in frontend storage keys +## 21) Magic strings are scattered in frontend storage keys - Where found: - [frontend/src/providers/AuthProvider.tsx](frontend/src/providers/AuthProvider.tsx) - [frontend/src/layouts/MainLayout.tsx](frontend/src/layouts/MainLayout.tsx) @@ -112,7 +73,7 @@ --- -## 23) No global cancellation policy on route transitions +## 22) No global cancellation policy on route transitions - Where found: - [frontend/src/hooks](frontend/src/hooks) - Why this is needed: @@ -130,7 +91,7 @@ --- -## 24) API response wrapper shape is inconsistent +## 23) API response wrapper shape is inconsistent - Where found: - [backend/app/routers/dashboard.py](backend/app/routers/dashboard.py) - [backend/app/routers/jails.py](backend/app/routers/jails.py) @@ -151,7 +112,7 @@ --- -## 25) No canonical snake_case/camelCase serialization policy +## 24) No canonical snake_case/camelCase serialization policy - Where found: - [backend/app/models/server.py](backend/app/models/server.py) - [frontend/src/types/server.ts](frontend/src/types/server.ts) @@ -171,7 +132,7 @@ --- -## 26) Pagination contract is not standardized across endpoints +## 25) Pagination contract is not standardized across endpoints - Where found: - [backend/app/routers/dashboard.py](backend/app/routers/dashboard.py) - [backend/app/routers/history.py](backend/app/routers/history.py) @@ -191,7 +152,7 @@ --- -## 27) Error response body shape is inconsistent +## 26) Error response body shape is inconsistent - Where found: - [backend/app/main.py](backend/app/main.py) - [backend/app/routers](backend/app/routers) @@ -211,7 +172,7 @@ --- -## 28) Login failure delay can enable app-layer DoS +## 27) Login failure delay can enable app-layer DoS - Where found: - [backend/app/routers/auth.py](backend/app/routers/auth.py#L110) - Why this is needed: @@ -229,7 +190,7 @@ --- -## 29) Blocklist URL validation has DNS-rebinding window +## 28) Blocklist URL validation has DNS-rebinding window - Where found: - [backend/app/utils/ip_utils.py](backend/app/utils/ip_utils.py#L145) - [backend/app/services/blocklist_service.py](backend/app/services/blocklist_service.py#L81) @@ -249,7 +210,7 @@ --- -## 30) Setup persistence is non-atomic across DB contexts +## 29) Setup persistence is non-atomic across DB contexts - Where found: - [backend/app/services/setup_service.py](backend/app/services/setup_service.py) - [backend/app/repositories/settings_repo.py](backend/app/repositories/settings_repo.py) @@ -268,7 +229,7 @@ --- -## 31) Fire-and-forget reschedule may fail silently +## 30) Fire-and-forget reschedule may fail silently - Where found: - [backend/app/tasks/blocklist_import.py](backend/app/tasks/blocklist_import.py#L108) - Why this is needed: @@ -286,7 +247,7 @@ --- -## 32) RateLimiter cleanup function is not scheduled/used +## 31) RateLimiter cleanup function is not scheduled/used - Where found: - [backend/app/utils/rate_limiter.py](backend/app/utils/rate_limiter.py#L84) - [backend/app/startup.py](backend/app/startup.py) @@ -305,7 +266,7 @@ --- -## 33) Trusted proxy configuration is hardcoded in auth router +## 32) Trusted proxy configuration is hardcoded in auth router - Where found: - [backend/app/routers/auth.py](backend/app/routers/auth.py#L46) - [backend/app/utils/client_ip.py](backend/app/utils/client_ip.py) @@ -325,7 +286,7 @@ --- -## 34) Setup redirect allowlist uses broad prefix matching +## 33) Setup redirect allowlist uses broad prefix matching - Where found: - [backend/app/main.py](backend/app/main.py#L434) - Why this is needed: @@ -343,7 +304,7 @@ --- -## 35) API client sends JSON and CSRF header for every request method +## 34) API client sends JSON and CSRF header for every request method - Where found: - [frontend/src/api/client.ts](frontend/src/api/client.ts) - Why this is needed: @@ -362,7 +323,7 @@ --- -## 36) Polling continues when tab is not visible +## 35) Polling continues when tab is not visible - Where found: - [frontend/src/hooks/usePolledData.ts](frontend/src/hooks/usePolledData.ts#L90) - [frontend/src/hooks/useBlocklistStatus.ts](frontend/src/hooks/useBlocklistStatus.ts) @@ -381,7 +342,7 @@ --- -## 37) Multi-worker safety check depends on one environment variable +## 36) Multi-worker safety check depends on one environment variable - Where found: - [backend/app/startup.py](backend/app/startup.py#L61) - Why this is needed: @@ -399,7 +360,7 @@ --- -## 38) History archive query paths may need explicit indexing plan +## 37) History archive query paths may need explicit indexing plan - Where found: - [backend/app/db.py](backend/app/db.py) - [backend/app/repositories/history_archive_repo.py](backend/app/repositories/history_archive_repo.py) @@ -420,7 +381,7 @@ --- -## 39) No explicit DI container strategy for backend service graph +## 38) No explicit DI container strategy for backend service graph - Where found: - [backend/app/dependencies.py](backend/app/dependencies.py) - [backend/app/services](backend/app/services) @@ -439,7 +400,7 @@ --- -## 40) Frontend and backend observability are not aligned +## 39) Frontend and backend observability are not aligned - Where found: - [backend/app/main.py](backend/app/main.py) - [frontend/src](frontend/src) diff --git a/Docs/Web-Development.md b/Docs/Web-Development.md index dd91dd2..8598320 100644 --- a/Docs/Web-Development.md +++ b/Docs/Web-Development.md @@ -246,6 +246,51 @@ const doBan = useCallback( - ❌ Passing API functions directly to components — couples components to API contract - ❌ Multiple domain hooks for the same data without deduplication — causes wasted requests and state desync +### Hook Architecture & Reusable Primitives + +BanGUI's hooks are built on composable primitives to eliminate duplication and enforce consistent patterns. + +**Base Hook: `useFetchData`** (`hooks/useFetchData.ts`) +- The foundation of all data-fetching hooks +- Encapsulates fetch lifecycle: abort controller management, loading/error state, cancellation safety +- Signature: `useFetchData(fetcher, selector, errorMessage, onSuccess?, initialData?) → { data, loading, error, refresh }` +- Not used directly by consumers; only composed by higher-level hooks +- Handles automatic cleanup on unmount (abort signal cancellation) + +**Tier 2 Hooks Built on `useFetchData`:** +- `useListData`: Wraps `useFetchData` with `initialData` defaulting to `[]` and returns `{ items, ... }` +- `usePolledData`: Wraps `useFetchData` and adds polling (interval) + window-focus refetch on top +- Additional specialized hooks can be added by composing `useFetchData` with domain-specific effects + +**Composition Pattern for New Hooks:** +When building a new Tier 2 hook with custom behavior, follow this pattern: +```ts +export function useMyCustomData(options: MyOptions): MyResult { + // 1. Use useFetchData for the base fetch lifecycle + const { data, loading, error, refresh } = useFetchData({ + fetcher: options.fetcher, + selector: options.selector, + errorMessage: options.errorMessage, + onSuccess: options.onSuccess, + initialData: options.initialData, + }); + + // 2. Add custom effects for additional behavior (e.g., polling, focus handling, custom cleanup) + useEffect(() => { + // Your custom logic here + }, [...dependencies]); + + // 3. Return a domain-specific result shape + return { data, loading, error, refresh, customField: /* derived */ }; +} +``` + +**Why this architecture?** +- **DRY**: Eliminates duplicate fetch logic across multiple hooks +- **Consistency**: All hooks share the same cancellation and error handling semantics +- **Testability**: Base hook can be tested in isolation; custom effects are minimal and easy to test +- **Maintainability**: Bug fixes to abort or error handling only need to happen once + --- ## 4. Code Organization diff --git a/frontend/src/hooks/__tests__/useFetchData.test.ts b/frontend/src/hooks/__tests__/useFetchData.test.ts new file mode 100644 index 0000000..a7e2b51 --- /dev/null +++ b/frontend/src/hooks/__tests__/useFetchData.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useFetchData } from "../useFetchData"; +import type { FetchError } from "../../types/api"; + +describe("useFetchData", () => { + it("fetches and selects data on mount", async () => { + const fetcher = vi.fn().mockResolvedValue({ value: "test" }); + const selector = vi.fn((response: { value: string }) => response.value); + + const { result } = renderHook(() => + useFetchData({ + fetcher, + selector, + errorMessage: "Failed to load", + }) + ); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBeUndefined(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(fetcher).toHaveBeenCalledTimes(1); + expect(selector).toHaveBeenCalledWith({ value: "test" }); + expect(result.current.data).toBe("test"); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it("uses initial data if provided", () => { + const fetcher = vi.fn().mockResolvedValue({ value: "updated" }); + const selector = vi.fn(); + + const { result } = renderHook(() => + useFetchData({ + fetcher, + selector, + errorMessage: "Failed to load", + initialData: "initial", + }) + ); + + expect(result.current.data).toBe("initial"); + expect(result.current.loading).toBe(true); + }); + + it("sets typed error when fetcher rejects", async () => { + const fetcher = vi.fn().mockRejectedValue(new Error("network error")); + const selector = vi.fn(); + + const { result } = renderHook(() => + useFetchData({ + fetcher, + selector, + errorMessage: "Failed to load", + }) + ); + + await act(async () => { + await Promise.resolve(); + }); + + const error = result.current.error as FetchError | null; + expect(error).not.toBeNull(); + expect(error?.type).toBe("network_error"); + expect(result.current.loading).toBe(false); + expect(result.current.data).toBeUndefined(); + }); + + it("calls onSuccess callback after successful fetch", async () => { + const response = { value: "success" }; + const fetcher = vi.fn().mockResolvedValue(response); + const selector = vi.fn((r: typeof response) => r.value); + const onSuccess = vi.fn(); + + const { result } = renderHook(() => + useFetchData({ + fetcher, + selector, + errorMessage: "Failed", + onSuccess, + }) + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(onSuccess).toHaveBeenCalledWith(response); + expect(result.current.data).toBe("success"); + }); + + it("refresh triggers a new fetch", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce({ value: "first" }) + .mockResolvedValueOnce({ value: "second" }); + const selector = vi.fn((response: { value: string }) => response.value); + + const { result } = renderHook(() => + useFetchData({ + fetcher, + selector, + errorMessage: "Failed to load", + }) + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.data).toBe("first"); + + await act(async () => { + result.current.refresh(); + await Promise.resolve(); + }); + + expect(fetcher).toHaveBeenCalledTimes(2); + expect(result.current.data).toBe("second"); + }); + + it("aborts previous request when refresh is called", async () => { + const abortedSignals: AbortSignal[] = []; + const fetcher = vi.fn().mockImplementation(async (signal: AbortSignal) => { + abortedSignals.push(signal); + await new Promise((resolve) => setTimeout(resolve, 50)); + return { value: "done" }; + }); + const selector = vi.fn(); + + const { result } = renderHook(() => + useFetchData({ + fetcher, + selector, + errorMessage: "Failed to load", + }) + ); + + await act(async () => { + await Promise.resolve(); + }); + + // Trigger refresh + await act(async () => { + result.current.refresh(); + await Promise.resolve(); + }); + + // First signal should have been aborted + expect(abortedSignals[0]?.aborted).toBe(true); + }); + + it("does not update state when signal is aborted", async () => { + const fetcher = vi.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return { value: "late" }; + }); + const selector = vi.fn((response: { value: string }) => response.value); + + const { result, unmount } = renderHook(() => + useFetchData({ + fetcher, + selector, + errorMessage: "Failed to load", + }) + ); + + // Unmount immediately + await act(async () => { + await Promise.resolve(); + }); + unmount(); + + // Data should not be updated + expect(result.current.data).toBeUndefined(); + expect(result.current.loading).toBe(true); // frozen at true due to abort + }); + + it("silently handles abort errors", async () => { + const fetcher = vi + .fn() + .mockRejectedValue(new DOMException("Aborted", "AbortError")); + const selector = vi.fn(); + + const { result } = renderHook(() => + useFetchData({ + fetcher, + selector, + errorMessage: "Failed to load", + }) + ); + + await act(async () => { + await Promise.resolve(); + }); + + // Abort errors should not set error state + expect(result.current.error).toBeNull(); + }); + + it("exposes typed API error information", async () => { + const fetcher = vi.fn().mockImplementation(() => { + return Promise.reject((() => { + const error = new Error("API error 500: Server error"); + (error as any).name = "ApiError"; + (error as any).status = 500; + (error as any).body = "Server error"; + return error; + })()); + }); + const selector = vi.fn(); + + const { result } = renderHook(() => + useFetchData({ + fetcher, + selector, + errorMessage: "Failed to load", + }) + ); + + await act(async () => { + await Promise.resolve(); + }); + + const error = result.current.error as FetchError | null; + expect(error).not.toBeNull(); + expect(error?.type).toBe("api_error"); + if (error?.type === "api_error") { + expect(error.status).toBe(500); + expect(error.body).toBe("Server error"); + } + }); +}); diff --git a/frontend/src/hooks/useFetchData.ts b/frontend/src/hooks/useFetchData.ts new file mode 100644 index 0000000..6bbaa1a --- /dev/null +++ b/frontend/src/hooks/useFetchData.ts @@ -0,0 +1,97 @@ +/** + * 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). + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { handleFetchError } from "../utils/fetchError"; +import type { FetchError } from "../types/api"; + +export interface UseFetchDataOptions { + /** Async function that accepts an AbortSignal for cancellation. */ + fetcher: (signal: AbortSignal) => Promise; + /** 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; +} + +export interface UseFetchDataResult { + /** 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. */ + refresh: () => void; +} + +/** + * 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. + * + * 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( + options: UseFetchDataOptions, +): UseFetchDataResult { + const { fetcher, selector, errorMessage, onSuccess, initialData } = options; + const [data, setData] = useState(initialData); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + 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]); + + useEffect(() => { + refresh(); + + return (): void => { + abortRef.current?.abort(); + }; + }, [refresh]); + + return { data, loading, error, refresh }; +} diff --git a/frontend/src/hooks/useListData.ts b/frontend/src/hooks/useListData.ts index d3cf99c..5a62607 100644 --- a/frontend/src/hooks/useListData.ts +++ b/frontend/src/hooks/useListData.ts @@ -1,22 +1,33 @@ /** * Generic hook for loading list data from an API endpoint. + * + * Composes useFetchData to provide a list-specific interface where initial + * data defaults to an empty array and the result is typed as `items: TItem[]`. */ -import { useCallback, useEffect, useRef, useState } from "react"; -import { handleFetchError } from "../utils/fetchError"; +import { useFetchData } from "./useFetchData"; import type { FetchError } from "../types/api"; export interface UseListDataOptions { + /** Async function that accepts an AbortSignal for cancellation. */ fetcher: (signal: AbortSignal) => Promise; + /** Synchronous selector to extract items array from response. */ selector: (response: TResponse) => TItem[]; + /** 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 items array. Defaults to empty array. */ initialItems?: TItem[]; } export interface UseListDataResult { + /** The extracted items array from the most recent successful fetch. */ items: TItem[]; + /** 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; } @@ -32,51 +43,20 @@ export interface UseListDataResult { * - `"abort_error"`: Request was cancelled (typically silently ignored by hook) * * @param options - Configuration options - * @returns Data, loading state, typed error, and refresh callback + * @returns List data, loading state, typed error, and refresh callback */ export function useListData( options: UseListDataOptions, ): UseListDataResult { const { fetcher, selector, errorMessage, onSuccess, initialItems } = options; - const [items, setItems] = useState(initialItems ?? []); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const abortRef = useRef(null); - const refresh = useCallback((): void => { - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; + const { data, loading, error, refresh } = useFetchData({ + fetcher, + selector, + errorMessage, + onSuccess, + initialData: initialItems ?? [], + }); - setLoading(true); - setError(null); - - fetcher(controller.signal) - .then((response) => { - if (controller.signal.aborted) return; - setItems(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]); - - useEffect(() => { - refresh(); - - return (): void => { - abortRef.current?.abort(); - }; - }, [refresh]); - - return { items, loading, error, refresh }; + return { items: data ?? [], loading, error, refresh }; } diff --git a/frontend/src/hooks/usePolledData.ts b/frontend/src/hooks/usePolledData.ts index 884ae61..07f4597 100644 --- a/frontend/src/hooks/usePolledData.ts +++ b/frontend/src/hooks/usePolledData.ts @@ -1,27 +1,38 @@ /** * 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. + * Composes useFetchData and adds polling and window-focus refetch semantics + * for non-list endpoints that need periodic updates. */ -import { useCallback, useEffect, useRef, useState } from "react"; -import { handleFetchError } from "../utils/fetchError"; +import { useEffect, useRef } from "react"; +import { useFetchData } from "./useFetchData"; import type { FetchError } from "../types/api"; export interface UsePolledDataOptions { + /** Async function that accepts an AbortSignal for cancellation. */ fetcher: (signal: AbortSignal) => Promise; + /** 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 { + /** 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; } @@ -52,66 +63,41 @@ export function usePolledData( refetchOnWindowFocus = true, } = options; - const [data, setData] = useState(initialData ?? null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const abortRef = useRef(null); - const fetchRef = useRef<() => void>((): void => undefined); + const { data, loading, error, refresh } = useFetchData({ + fetcher, + selector, + errorMessage, + onSuccess, + initialData, + }); - 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; + const refreshRef = useRef(refresh); useEffect(() => { - refresh(); + refreshRef.current = refresh; + }, [refresh]); + // Polling effect: set up interval if pollInterval is provided + useEffect(() => { if (!pollInterval) { - return (): void => { - abortRef.current?.abort(); - }; + return; } const id = setInterval((): void => { - fetchRef.current(); + refreshRef.current(); }, pollInterval); return (): void => { clearInterval(id); - abortRef.current?.abort(); }; - }, [refresh, pollInterval]); + }, [pollInterval]); - // Refetch on window focus if enabled. + // Window focus: optional refetch on regain focus useEffect(() => { if (!refetchOnWindowFocus) return; const onFocus = (): void => { - fetchRef.current(); + refreshRef.current(); }; window.addEventListener("focus", onFocus); @@ -120,5 +106,5 @@ export function usePolledData( }; }, [refetchOnWindowFocus]); - return { data, loading, error, refresh }; + return { data: data ?? null, loading, error, refresh }; }