/** * 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 { usePageVisibility } from "./usePageVisibility"; 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; /** * If true, pause polling when the page is hidden (user switched to another tab). * Polling resumes immediately when the page becomes visible, with an immediate refresh. * Defaults to false for backward compatibility. */ pauseWhenHidden?: 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; } /** * 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) * * When `pauseWhenHidden` is enabled, polling pauses when the page becomes hidden * and resumes immediately with a fresh fetch when the page becomes visible again. * * @param options - Configuration options * @returns Data, loading state, typed error, and refresh callback */ export function usePolledData( options: UsePolledDataOptions, ): UsePolledDataResult { const { fetcher, selector, errorMessage, onSuccess, initialData, pollInterval, refetchOnWindowFocus = true, pauseWhenHidden = false, } = options; const { data, loading, error, refresh } = useFetchData({ fetcher, selector, errorMessage, onSuccess, initialData, }); const refreshRef = useRef(refresh); const intervalIdRef = useRef(null); const previousVisibilityRef = useRef(null); const isVisible = usePageVisibility(); useEffect(() => { refreshRef.current = refresh; }, [refresh]); // Polling effect: set up interval if pollInterval is provided and page is visible useEffect(() => { if (!pollInterval) { return; } // If pauseWhenHidden is enabled and page is hidden, clear any existing interval if (pauseWhenHidden && !isVisible) { if (intervalIdRef.current !== null) { clearInterval(intervalIdRef.current); intervalIdRef.current = null; } return; } const id = window.setInterval((): void => { refreshRef.current(); }, pollInterval); intervalIdRef.current = id; return (): void => { if (intervalIdRef.current === id) { clearInterval(id); intervalIdRef.current = null; } }; }, [pollInterval, pauseWhenHidden, isVisible]); // Visibility effect: handle pause/resume when page visibility changes useEffect(() => { if (!pauseWhenHidden || !pollInterval) { return; } // Only refresh if this is a transition from hidden to visible, not on mount if (previousVisibilityRef.current === false && isVisible) { // Page became visible: clear pending interval and refresh immediately if (intervalIdRef.current !== null) { clearInterval(intervalIdRef.current); intervalIdRef.current = null; } refreshRef.current(); } previousVisibilityRef.current = isVisible; }, [isVisible, pauseWhenHidden, 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 }; }