跳至主要内容

狀態管理

本文檔說明花店管理系統的前端狀態管理策略。

狀態分類

類型範圍工具範例
Server State全域React QueryAPI 資料、快取
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' } }
}

下一步