/** * useFetch — 簡易 data fetching hook(不依賴 SWR / TanStack Query) * * 設計原則(對齊 F5 任務要求): * - 雛形不裝第三方 server state library;自己寫個基本版 * - 支援基本的 `data / error / isLoading / refetch` 四態 * - caller 可傳 options.enabled=false 暫停自動 fetch(例如等 auth ready) * - 使用 AbortController 防止 race condition(快速切路由 / 重新 mount 時) * - 不做快取層(雛形夠用;Phase 1 升級 SWR / TanStack Query 再加) * * 用法範例: * const { data, error, isLoading, refetch } = useFetch("/api/devices"); */ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { api, type RequestOptions } from "@/lib/api"; export interface UseFetchOptions extends Omit { /** 是否啟用自動 fetch(預設 true)。設 false 可手動呼叫 refetch */ enabled?: boolean; /** 初始 data(SSR hydrate 或 fallback) */ initialData?: T; /** * 依賴陣列 — 任一值變動會觸發 refetch。 * 通常放 path 的動態段,例如 `[deviceId]`。path 本身已包含在 deps 所以不需要重複傳。 */ deps?: ReadonlyArray; } export interface UseFetchResult { data: T | undefined; error: Error | null; isLoading: boolean; /** 手動觸發 refetch;回傳新的 data 或 throw */ refetch: () => Promise; } /** * 呼叫 `api.get(path)` 的 React hook。 * * ⚠️ 要點: * - path 作為主 key;path 變動自動重 fetch * - 支援取消:unmount 或 path 變動時,前一次請求會被 abort,state 不會被覆寫 * - 不做 stale-while-revalidate / dedup — 雛形不需要 */ export function useFetch( path: string, options: UseFetchOptions = {}, ): UseFetchResult { const { enabled = true, initialData, deps = [], ...requestOptions } = options; const [data, setData] = useState(initialData); const [error, setError] = useState(null); /** * fetchState 追蹤目前請求階段: * - "idle":尚未或被 disable,不顯示 loading * - "loading":請求中 * - "success" / "error":已結束 * * 為何不用 boolean `isLoading`: * React 19 `react-hooks/set-state-in-effect` 禁止在 effect body 直接 setState。 * 改為每個狀態只會由「特定動作」觸發 setState(doFetch 動作、disable 時直接讀 enabled), * 避免 effect 內做派生。 * * 對 caller 仍回傳 boolean `isLoading`,對外 API 不變。 */ const [fetchState, setFetchState] = useState<"idle" | "loading" | "success" | "error">( enabled ? "loading" : "idle", ); // 保存 options ref 避免 useCallback deps 抖動。於 effect 內更新以符合 React 19 lint。 const optionsRef = useRef(requestOptions); useEffect(() => { optionsRef.current = requestOptions; }); /** 目前在途的 abort controller(用來取消 stale 請求) */ const activeCtrlRef = useRef(null); const doFetch = useCallback(async (): Promise => { // 取消前一次 activeCtrlRef.current?.abort(); const ctrl = new AbortController(); activeCtrlRef.current = ctrl; setFetchState("loading"); setError(null); try { const result = await api.get(path, { ...optionsRef.current, signal: ctrl.signal, }); // 若這個 request 已被取消,不要更新 state(另一個 request 正在跑) if (ctrl.signal.aborted) return undefined; setData(result); setFetchState("success"); return result; } catch (err) { if (ctrl.signal.aborted) return undefined; const e = err instanceof Error ? err : new Error(String(err)); setError(e); setFetchState("error"); throw e; } }, [path]); useEffect(() => { if (!enabled) { // enabled=false 時不發 API;fetchState 仍保持原值(若原先已是 success 的資料仍然顯示) return; } // 推到下一個 microtask 才開始 fetch: // - 避免 React 19 lint `react-hooks/set-state-in-effect`(effect body 同步 setState) // - 不影響行為,只是把 setFetchState("loading") 排到 microtask queue // - cleanup 時若 effect 在尚未啟動前被解除,由 ctrl.abort() 統一處理 queueMicrotask(() => { void doFetch().catch(() => { // error state 已在 doFetch 設置,這裡 catch 只是為了避免 unhandled rejection }); }); return () => { activeCtrlRef.current?.abort(); }; // deps: path + caller 提供的 extra deps + enabled // eslint-disable-next-line react-hooks/exhaustive-deps }, [path, enabled, ...deps]); // 對外仍提供 boolean `isLoading`,由 fetchState 派生 const isLoading = enabled && fetchState === "loading"; return { data, error, isLoading, refetch: doFetch }; }