狀態管理
本文檔說明花店管理系統的前端狀態管理策略。
狀態分類
| 類型 | 範圍 | 工具 | 範例 |
|---|---|---|---|
| Server State | 全域 | React Query | API 資料、快取 |
| Client State | 全域 | Redux Toolkit | 使用者偏好、UI 狀態 |
| Component State | 區域 | useState/useReducer | 表單輸入、展開狀態 |
| URL State | 全域 | React Router | 分頁、篩選條件 |
Server State(React Query)
查詢資料
// hooks/useProducts.ts
import { useQuery } from '@tanstack/react-query';
import { productApi } from '@/services/product';
export function useProducts(filter?: ProductFilter) {
return useQuery({
queryKey: ['products', filter],
queryFn: () => productApi.findAll(filter),
staleTime: 5 * 60 * 1000, // 5 分鐘內視為新鮮
});
}
// 使用
function ProductList() {
const { data, isLoading, error } = useProducts({ status: 'ACTIVE' });
if (isLoading) return <Loading />;
if (error) return <Error message={error.message} />;
return <ProductTable data={data} />;
}
變更資料
// hooks/useCreateProduct.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { productApi } from '@/services/product';
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: productApi.create,
onSuccess: () => {
// 使快取失效,觸發重新載入
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
// 使用
function CreateProductForm() {
const createProduct = useCreateProduct();
const handleSubmit = async (data: ProductFormData) => {
await createProduct.mutateAsync(data);
};
return <Form onSubmit={handleSubmit} />;
}
樂觀更新
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: productApi.update,
onMutate: async (newProduct) => {
// 取消進行中的查詢
await queryClient.cancelQueries({ queryKey: ['products'] });
// 保存舊資料
const previousProducts = queryClient.getQueryData(['products']);
// 樂觀更新
queryClient.setQueryData(['products'], (old) =>
old?.map((p) => (p.id === newProduct.id ? newProduct : p))
);
return { previousProducts };
},
onError: (err, newProduct, context) => {
// 失敗時回滾
queryClient.setQueryData(['products'], context?.previousProducts);
},
});
}
Client State(Redux Toolkit)
Store 結構
store/
├── index.ts # Store 配置
├── hooks.ts # Typed hooks
└── slices/
├── authSlice.ts # 認證狀態
├── uiSlice.ts # UI 狀態
└── preferencesSlice.ts # 使用者偏好
Slice 定義
// slices/uiSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface UiState {
sidebarCollapsed: boolean;
theme: 'light' | 'dark';
notifications: Notification[];
}
const initialState: UiState = {
sidebarCollapsed: false,
theme: 'light',
notifications: [],
};
export const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
toggleSidebar: (state) => {
state.sidebarCollapsed = !state.sidebarCollapsed;
},
setTheme: (state, action: PayloadAction<'light' | 'dark'>) => {
state.theme = action.payload;
},
addNotification: (state, action: PayloadAction<Notification>) => {
state.notifications.push(action.payload);
},
removeNotification: (state, action: PayloadAction<string>) => {
state.notifications = state.notifications.filter(
(n) => n.id !== action.payload
);
},
},
});
export const { toggleSidebar, setTheme, addNotification, removeNotification } =
uiSlice.actions;
使用 Redux State
// 使用 typed hooks
import { useAppSelector, useAppDispatch } from '@/store/hooks';
import { toggleSidebar } from '@/store/slices/uiSlice';
function Sidebar() {
const collapsed = useAppSelector((state) => state.ui.sidebarCollapsed);
const dispatch = useAppDispatch();
return (
<aside className={collapsed ? 'w-16' : 'w-64'}>
<button onClick={() => dispatch(toggleSidebar())}>
Toggle
</button>
</aside>
);
}
Component State
簡單狀態 - useState
function SearchBox() {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setIsOpen(true)}
/>
{isOpen && <SearchResults query={query} />}
</div>
);
}
複雜狀態 - useReducer
type State = {
items: CartItem[];
total: number;
};
type Action =
| { type: 'ADD_ITEM'; payload: CartItem }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'CLEAR' };
function cartReducer(state: State, action: Action): State {
switch (action.type) {
case 'ADD_ITEM':
return {
items: [...state.items, action.payload],
total: state.total + action.payload.price,
};
case 'REMOVE_ITEM':
const item = state.items.find((i) => i.id === action.payload);
return {
items: state.items.filter((i) => i.id !== action.payload),
total: state.total - (item?.price ?? 0),
};
case 'CLEAR':
return { items: [], total: 0 };
}
}
function Cart() {
const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 });
return (
<div>
{state.items.map((item) => (
<CartItem
key={item.id}
item={item}
onRemove={() => dispatch({ type: 'REMOVE_ITEM', payload: item.id })}
/>
))}
<p>Total: ${state.total}</p>
</div>
);
}
URL State
使用 React Router
import { useSearchParams } from 'react-router-dom';
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page')) || 1;
const status = searchParams.get('status') || 'all';
const handlePageChange = (newPage: number) => {
setSearchParams({ ...Object.fromEntries(searchParams), page: String(newPage) });
};
const handleStatusChange = (newStatus: string) => {
setSearchParams({ status: newStatus, page: '1' });
};
return (
<div>
<StatusFilter value={status} onChange={handleStatusChange} />
<ProductTable page={page} status={status} />
<Pagination page={page} onChange={handlePageChange} />
</div>
);
}
最佳實踐
1. 選擇正確的狀態類型
- API 資料 → React Query
- 全域 UI 狀態 → Redux
- 表單資料 → Component State
- 篩選/分頁 → URL State
2. 避免過度使用全域狀態
// ❌ 不好:所有狀態都放 Redux
const productFormData = useAppSelector(state => state.productForm);
// ✅ 好:表單狀態保持在組件內
const [formData, setFormData] = useState<ProductFormData>({});
3. 保持狀態正規化
// ❌ 不好:巢狀結構
{
orders: [
{ id: 1, customer: { id: 1, name: 'John' } }
]
}
// ✅ 好:正規化結構
{
orders: { 1: { id: 1, customerId: 1 } },
customers: { 1: { id: 1, name: 'John' } }
}