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 | 延遲執行的函數 |
delay | number | 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 | 週期執行的函數 |
delay | number | 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 比較
| 特性 | useTimeout | useInterval |
|---|---|---|
| 執行方式 | 單次執行 | 週期執行 |
| 底層 API | setTimeout | setInterval |
| 適用場景 | 延遲操作、防抖 | 輪詢、動畫、計時器 |
| 清理機制 | clearTimeout | clearInterval |
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 回傳 content 和 totalElements 欄位。
:::
設計原則
- 與 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.top 和 position.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) |
| useInfiniteList | TanStack 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 文檔
- React Hooks API Reference
- Dan Abramov - Making setInterval Declarative with React Hooks
- React Hooks Best Practices
社群 Hooks 參考
- usehooks-ts - TypeScript Hooks 庫
- react-use - 流行的 Hooks 庫
- ahooks - 阿里巴巴 Hooks 庫