跳至主要内容

React Hooks

React Hooks 工具庫,提供常用的自訂 Hooks 以簡化 React 應用開發。

模組總覽

Hook用途核心功能
useTimeout單次計時器聲明式 setTimeout,自動清理
useInterval週期計時器聲明式 setInterval,自動清理
useInfiniteList無限滾動與 VirtualTable 整合的 TanStack Query 封裝
useFloatingPortal浮動元素定位Portal 渲染與位置計算

快速開始

安裝

npm install @appfuse/appfuse-web

使用方式

統一導入

import { useTimeout, useInterval } from '@appfuse/appfuse-web/hooks';

function Component() {
// 單次執行(3 秒後)
useTimeout(() => {
console.log('3 秒後執行一次');
}, 3000);

// 週期執行(每秒)
useInterval(() => {
console.log('每秒執行一次');
}, 1000);
}

個別模組導入(Tree-shaking 優化)

import { useTimeout } from '@appfuse/appfuse-web/hooks/use-timeout';
import { useInterval } from '@appfuse/appfuse-web/hooks/use-interval';

:::tip Tree-shaking 建議 若只需使用單一 Hook,建議使用個別模組導入,以減少最終 bundle 大小。 :::


Hooks 詳細說明

useTimeout

聲明式 setTimeout,自動處理清理邏輯,避免記憶體洩漏。

類型簽名

function useTimeout(callback: () => void, delay: number | null): void

參數

參數型別說明
callback() => void延遲執行的函數
delaynumber | null延遲時間 (ms),傳入 null 可暫停

設計原則

遵循 Dan Abramov 推薦的模式(參考 Making setInterval Declarative with React Hooks):

  • 使用 useRef 保存最新的 callback,避免 stale closure 問題
  • 正確處理 cleanup(clearTimeout
  • 支援動態參數(null 可暫停執行)

使用場景

  • 延遲顯示工具提示 (tooltip)
  • 自動關閉通知 (toast)
  • 延遲載入內容
  • 防抖 (debounce) 的基礎實作

範例

自動關閉通知
import { useState } from 'react';
import { useTimeout } from '@appfuse/appfuse-web/hooks';

function AutoCloseToast() {
const [visible, setVisible] = useState(true);

// 3 秒後自動關閉
useTimeout(() => {
setVisible(false);
}, 3000);

if (!visible) return null;

return <div className="toast">自動關閉的通知</div>;
}
動態暫停/恢復
import { useState } from 'react';
import { useTimeout } from '@appfuse/appfuse-web/hooks';

function DelayedMessage() {
const [paused, setPaused] = useState(false);

useTimeout(() => {
alert('5 秒已到!');
}, paused ? null : 5000); // 傳入 null 暫停計時器

return (
<button onClick={() => setPaused(!paused)}>
{paused ? '恢復' : '暫停'}
</button>
);
}
延遲載入內容
import { useState } from 'react';
import { useTimeout } from '@appfuse/appfuse-web/hooks';

function LazyContent() {
const [showContent, setShowContent] = useState(false);

// 等待 1 秒後顯示內容,改善首屏載入體驗
useTimeout(() => {
setShowContent(true);
}, 1000);

return (
<div>
<h1>主要內容</h1>
{showContent && <HeavyComponent />}
</div>
);
}

useInterval

聲明式 setInterval,自動處理清理邏輯,確保 callback 總是使用最新的 props 和 state。

類型簽名

function useInterval(callback: () => void, delay: number | null): void

參數

參數型別說明
callback() => void週期執行的函數
delaynumber | null間隔時間 (ms),傳入 null 可暫停

設計原則

遵循 Dan Abramov 推薦的模式(參考 Making setInterval Declarative with React Hooks):

  • 使用 useRef 保存最新的 callback,確保每次執行都能存取最新的 props/state
  • 正確處理 cleanup(clearInterval
  • 支援動態參數(null 可暫停執行)

使用場景

  • 進度條自動更新
  • 輪詢 API 資料
  • 倒數計時器
  • 自動輪播
  • 即時資料同步

範例

進度條更新
import { useState } from 'react';
import { useInterval } from '@appfuse/appfuse-web/hooks';

function ProgressBar() {
const [progress, setProgress] = useState(0);

useInterval(() => {
setProgress(prev => (prev >= 100 ? 0 : prev + 10));
}, 500);

return <progress value={progress} max={100} />;
}
倒數計時器
import { useState } from 'react';
import { useInterval } from '@appfuse/appfuse-web/hooks';

function Countdown({ initialSeconds }: { initialSeconds: number }) {
const [seconds, setSeconds] = useState(initialSeconds);

// 每秒減 1(當倒數至 0 時暫停)
useInterval(() => {
setSeconds(prev => prev - 1);
}, seconds > 0 ? 1000 : null);

return (
<div>
{seconds > 0 ? `剩餘 ${seconds}` : '時間到!'}
</div>
);
}
自動輪播
import { useState } from 'react';
import { useInterval } from '@appfuse/appfuse-web/hooks';

function ImageCarousel({ images }: { images: string[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isHovered, setIsHovered] = useState(false);

// 滑鼠懸停時暫停輪播
useInterval(() => {
setCurrentIndex(prev => (prev + 1) % images.length);
}, isHovered ? null : 3000);

return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<img src={images[currentIndex]} alt={`Slide ${currentIndex}`} />
</div>
);
}

useTimeout vs useInterval 比較

特性useTimeoutuseInterval
執行方式單次執行週期執行
底層 APIsetTimeoutsetInterval
適用場景延遲操作、防抖輪詢、動畫、計時器
清理機制clearTimeoutclearInterval

useInfiniteList

基於 TanStack Query 的通用無限滾動 Hook,提供與 VirtualTable 相容的無限滾動功能。

類型簽名

function useInfiniteList<TData, TFilters>(
options: UseInfiniteListOptions<TData, TFilters>
): UseInfiniteListResult<TData>

Options 參數

interface UseInfiniteListOptions<TData, TFilters> {
/** Query Key 陣列(用於識別快取) */
queryKey: readonly unknown[]

/** 資料查詢函數 */
queryFn: (params: {
page: number
size: number
filters: TFilters
sorting: SortingState
}) => Promise<PagedResult<TData>>

/** 每頁大小(預設:20) */
pageSize?: number

/** 篩選條件 */
filters: TFilters

/** 排序狀態 */
sorting: SortingState

/** 是否啟用查詢(預設:true) */
enabled?: boolean

/** 快取過期時間(毫秒,預設:5 分鐘) */
staleTime?: number

/** 垃圾回收時間(毫秒,預設:1 小時) */
gcTime?: number
}

回傳值

interface UseInfiniteListResult<TData> {
/** 扁平化的所有資料 */
data: TData[]

/** 首次載入狀態 */
isLoading: boolean

/** 正在載入更多 */
isFetchingNextPage: boolean

/** 是否有更多資料 */
hasNextPage: boolean

/** 錯誤訊息 */
error: string | null

/** 伺服器端總筆數 */
totalCount: number

/** VirtualTable 用的 InfiniteScrollInfo(直接傳入即可) */
infiniteScroll: InfiniteScrollInfo

/** 載入下一頁 */
fetchNextPage: () => void

/** 重新整理 */
refetch: () => void

/** 失效快取(用於 CRUD 後) */
invalidate: () => void
}

PagedResult 格式

後端 API 應回傳此格式的資料結構:

interface PagedResult<T> {
/** 資料列表 */
content: T[]
/** 總筆數 */
totalElements: number
/** 當前頁碼(從 0 開始,選填) */
page?: number
/** 每頁筆數(選填) */
size?: number
}

:::info 後端整合 PagedResult 對應 Spring Boot 的 Page 回傳格式。確保後端 API 回傳 contenttotalElements 欄位。 :::

設計原則

  • 與 VirtualTable 無縫整合:輸出 infiniteScroll 可直接傳給 VirtualTable
  • 自動快取管理:當 filters/sorting 變更時自動 refetch
  • 簡化樣板程式碼:封裝 useInfiniteQuery 的複雜設定
  • 提供 CRUD 支援invalidate() 方法供資料變更後呼叫

使用場景

  • 訂單列表無限滾動
  • 商品目錄瀏覽
  • 日誌記錄查詢
  • 任何需要大量資料分頁載入的場景

範例

與 VirtualTable 整合
import { useState } from 'react'
import { VirtualTable, useInfiniteList } from '@appfuse/appfuse-web'
import type { SortingState, VirtualTableColumn } from '@appfuse/appfuse-web'

interface Order {
id: string
orderNumber: string
customerName: string
total: number
}

interface OrderFilters {
status?: string[]
searchTerm?: string
}

const columns: VirtualTableColumn<Order>[] = [
{ accessorKey: 'orderNumber', header: '訂單編號', enableSorting: true },
{ accessorKey: 'customerName', header: '客戶名稱' },
{ accessorKey: 'total', header: '金額', meta: { align: 'right' } },
]

function OrderList() {
const [filters, setFilters] = useState<OrderFilters>({})
const [sorting, setSorting] = useState<SortingState>([])

const {
data,
isLoading,
infiniteScroll,
fetchNextPage,
} = useInfiniteList<Order, OrderFilters>({
queryKey: ['orders'],
queryFn: async ({ page, size, filters, sorting }) => {
return orderService.query({
predicate: buildPredicate(filters),
sort: sorting.map((s) => `${s.id},${s.desc ? 'desc' : 'asc'}`).join('&'),
page,
size,
})
},
filters,
sorting,
})

return (
<VirtualTable
data={data}
columns={columns}
height="70vh"
sorting={sorting}
onSortingChange={setSorting}
infiniteScroll={infiniteScroll}
onFetchNextPage={fetchNextPage}
loading={isLoading}
showRowNumber
/>
)
}
搭配 CRUD 操作
import { useInfiniteList } from '@appfuse/appfuse-web'

function OrderManager() {
const { data, invalidate, ...rest } = useInfiniteList({
queryKey: ['orders'],
queryFn: orderService.query,
filters: {},
sorting: [],
})

const handleCreate = async (newOrder: CreateOrderRequest) => {
await orderService.create(newOrder)
invalidate() // 失效快取,觸發重新載入
}

const handleDelete = async (id: string) => {
await orderService.delete(id)
invalidate()
}

// ...
}

:::tip CRUD 後的快取策略 呼叫 invalidate() 會讓快取失效並觸發重新載入,確保使用者看到最新資料。這比手動更新本地快取更簡單可靠。 :::

與手動 useInfiniteQuery 比較

特性useInfiniteList手動 useInfiniteQuery
程式碼行數~10 行~30 行
VirtualTable 整合直接傳入 infiniteScroll需手動建構 InfiniteScrollInfo
資料扁平化自動處理需手動 flatMap
filters/sorting 快取自動加入 queryKey需手動處理
invalidate 方法內建需額外 hook

useFloatingPortal

提供浮動元素的 Portal 定位功能,解決 overflow 容器裁切問題。

類型簽名

function useFloatingPortal(options: UseFloatingPortalOptions): UseFloatingPortalReturn

Options 參數

interface UseFloatingPortalOptions {
/** 是否開啟浮動元素 */
isOpen: boolean
/** 觸發元素的 ref */
triggerRef: RefObject<HTMLElement | null>
/** 浮動內容元素的 ref */
contentRef: RefObject<HTMLElement | null>
/** 水平對齊方式 @default 'start' */
align?: 'start' | 'end'
/** 垂直位置 @default 'bottom' */
position?: 'top' | 'bottom'
/** 觸發元素與浮動元素的間距 (px) @default 4 */
offset?: number
/** 與視窗邊緣的最小間距 (px) @default 8 */
edgePadding?: number
/** 是否自動調整位置以避免超出視窗 @default true */
autoFlip?: boolean
/** 是否匹配觸發元素的寬度 @default false */
matchTriggerWidth?: boolean
}

回傳值

interface UseFloatingPortalReturn {
/** Portal 容器元素 */
portalContainer: HTMLElement | null
/** 計算後的位置 */
position: { top: number; left: number; width?: number }
/** 渲染到 Portal 的輔助函數 */
renderPortal: (content: React.ReactNode) => React.ReactPortal | null
}

使用場景

  • Dropdown 選單
  • Tooltip 工具提示
  • Popover 彈出框
  • 任何需要突破 overflow 容器的浮動元素

範例

import { useRef, useState } from 'react'
import { useFloatingPortal } from '@appfuse/appfuse-web/hooks'

function Dropdown() {
const [isOpen, setIsOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const contentRef = useRef<HTMLDivElement>(null)

const { position, renderPortal } = useFloatingPortal({
isOpen,
triggerRef,
contentRef,
align: 'start',
position: 'bottom',
})

return (
<>
<button ref={triggerRef} onClick={() => setIsOpen(!isOpen)}>
Open Menu
</button>
{renderPortal(
<div
ref={contentRef}
style={{
position: 'fixed',
top: position.top,
left: position.left,
}}
>
<ul>
<li>Option 1</li>
<li>Option 2</li>
</ul>
</div>
)}
</>
)
}

設計特點

  • 自動邊界檢測:當空間不足時自動翻轉位置
  • 滾動同步:監聽滾動事件,自動更新位置
  • 視窗調整:監聽 resize 事件,保持正確定位
  • Portal 渲染:渲染到 body,避免 overflow 裁切

:::warning 注意事項 使用 useFloatingPortal 時,浮動內容的 style 必須設定 position: 'fixed',並使用回傳的 position.topposition.left 作為定位座標。 :::


設計原則

符合 React 最佳實踐

所有 Hooks 遵循 React 官方推薦的模式:

  • 正確的依賴管理
  • 適當的清理機制
  • 避免常見的閉包陷阱

TypeScript 類型安全

所有 Hooks 提供完整的 TypeScript 類型定義,無 any 類型。

Tree-shaking 友好

所有 Hooks 支援 ES Module,僅打包使用的程式碼。

// 僅打包 useTimeout
import { useTimeout } from '@appfuse/appfuse-web/hooks/use-timeout';

// 可接受,但會打包整個 hooks
import { useTimeout } from '@appfuse/appfuse-web/hooks';

零依賴(或最小依賴)

Hook依賴
useTimeout僅 React 核心 API
useInterval僅 React 核心 API
useFloatingPortal僅 React 核心 API(useState、useRef、useLayoutEffect)
useInfiniteListTanStack Query(@tanstack/react-query

最佳實踐

避免在 callback 中使用過時的狀態

// highlight-error-start
// 錯誤示範(Stale Closure)
function Counter() {
const [count, setCount] = useState(0);

useTimeout(() => {
console.log(count); // 永遠是 0(初始值)
}, 3000);

return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}
// highlight-error-end
// highlight-success-start
// 正確做法(使用 functional update)
function Counter() {
const [count, setCount] = useState(0);

useTimeout(() => {
setCount(c => {
console.log(c); // 獲取最新的 count
return c;
});
}, 3000);

return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}
// highlight-success-end

避免過度使用

對於複雜的計時器邏輯,考慮使用專門的 Hook:

// 避免:複雜的 useTimeout 組合
function ComplexTimer() {
useTimeout(() => { /* ... */ }, delay1);
useTimeout(() => { /* ... */ }, delay2);
useTimeout(() => { /* ... */ }, delay3);
}

// 建議:自訂 Hook
function useMultiStepTimer() {
useEffect(() => {
const timer1 = setTimeout(() => { /* step 1 */ }, 1000);
const timer2 = setTimeout(() => { /* step 2 */ }, 2000);
const timer3 = setTimeout(() => { /* step 3 */ }, 3000);

return () => {
clearTimeout(timer1);
clearTimeout(timer2);
clearTimeout(timer3);
};
}, []);
}

模組結構

lib/hooks/
├── index.ts # 統一導出
├── use-timeout.tsx # setTimeout Hook
├── use-timeout.test.tsx # useTimeout 測試
├── use-interval.tsx # setInterval Hook
├── use-interval.test.tsx # useInterval 測試
├── use-infinite-list.tsx # TanStack Query 無限滾動 Hook
├── use-floating-portal.tsx # Floating UI Portal Hook
└── README.md # 原始文檔(source of truth)

相關資源

React Hooks 文檔

社群 Hooks 參考