狀態管理
AppFuse Web 使用 Redux Toolkit 進行全域狀態管理,提供型別安全的 Store、自動日期序列化和認證持久化。
架構概覽
Store 結構
src/
├── store/
│ ├── index.ts # Store 配置
│ ├── hooks.ts # useAppDispatch, useAppSelector
│ └── serializers.ts # 日期序列化工具
│
└── features/
├── iam/ # 身份認證
│ └── me-slice.ts
├── app/ # 應用系統
│ └── sys-slice.ts
├── sales/ # 銷售模組
│ ├── order-finder-slice.ts
│ └── draft-orders-slice.ts
├── crm/ # 客戶管理
│ └── customer-finder-slice.ts
├── product/ # 產品管理
│ └── product-finder-slice.ts
└── root-reducers.ts # 根 Reducer 組合
基本用法
Store 配置
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { rootReducers } from '@/features/root-reducers';
import { dateSerializerMiddleware } from './date-serializer-middleware';
import { authStorageMiddleware } from './auth-storage-middleware';
export const store = configureStore({
reducer: rootReducers,
preloadedState: loadStateFromStorage(),
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST'],
ignoredPaths: ['iam.me.authorization'],
},
})
.concat(dateSerializerMiddleware)
.concat(authStorageMiddleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
自訂 Hooks
// src/store/hooks.ts
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';
import { deserializeDates } from './serializers';
// 型別安全的 dispatch
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
// 自動反序列化日期的 selector
export function useAppSelector<T>(selector: (state: RootState) => T): T {
const result = useSelector(selector);
return deserializeDates(result);
}
在元件中使用
import { useAppDispatch, useAppSelector } from '@/store';
import { selectCurrentUser, logout } from '@/features/iam';
function UserMenu() {
const dispatch = useAppDispatch();
const currentUser = useAppSelector(selectCurrentUser);
const handleLogout = () => {
dispatch(logout());
};
return (
<div>
<span>{currentUser?.name}</span>
<button onClick={handleLogout}>Logout</button>
</div>
);
}
建立 Slice
基本 Slice
// src/features/product/product-finder-slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '@/store';
import type { SortingState } from '@tanstack/react-table';
interface ProductFinderState {
searchTerm: string;
filters: {
category: string | null;
status: string | null;
};
sorting: SortingState;
scrollTop: number;
}
const initialState: ProductFinderState = {
searchTerm: '',
filters: {
category: null,
status: null,
},
sorting: [{ id: 'updatedAt', desc: true }],
scrollTop: 0,
};
const productFinderSlice = createSlice({
name: 'product/finder',
initialState,
reducers: {
setSearchTerm: (state, action: PayloadAction<string>) => {
state.searchTerm = action.payload;
state.scrollTop = 0; // 重置滾動位置
},
setFilters: (state, action: PayloadAction<Partial<typeof initialState.filters>>) => {
state.filters = { ...state.filters, ...action.payload };
state.scrollTop = 0;
},
setSorting: (state, action: PayloadAction<SortingState>) => {
state.sorting = action.payload;
state.scrollTop = 0;
},
setScrollTop: (state, action: PayloadAction<number>) => {
state.scrollTop = action.payload;
},
resetFinderState: () => initialState,
},
});
export const {
setSearchTerm,
setFilters,
setSorting,
setScrollTop,
resetFinderState,
} = productFinderSlice.actions;
export default productFinderSlice.reducer;
// Selectors
export const selectSearchTerm = (state: RootState) => state.product.finder.searchTerm;
export const selectFilters = (state: RootState) => state.product.finder.filters;
export const selectSorting = (state: RootState) => state.product.finder.sorting;
export const selectScrollTop = (state: RootState) => state.product.finder.scrollTop;
非同步 Thunk
使用 createAppAsyncThunk 處理非同步操作:
// src/features/iam/me-slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { createAppAsyncThunk } from '@/store/create-app-async-thunk';
import { authService } from '@/services/auth-service';
// 定義型別
interface Authorization {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
}
interface User {
id: string;
email: string;
name: string;
roles: string[];
}
interface MeState {
authorization: Authorization | null;
user: User | null;
}
const initialState: MeState = {
authorization: null,
user: null,
};
// 非同步 Thunk
export const login = createAppAsyncThunk(
'iam/login',
async (credentials: { username: string; password: string; remember: boolean }) => {
const authorization = await authService.login(credentials);
const user = await authService.getCurrentUser(authorization.access_token);
return { authorization, user, remember: credentials.remember };
}
);
export const fetchCurrentUser = createAppAsyncThunk(
'iam/fetchCurrentUser',
async (accessToken: string) => {
return await authService.getCurrentUser(accessToken);
}
);
// Slice
const meSlice = createSlice({
name: 'iam/me',
initialState,
reducers: {
logout: () => initialState,
},
extraReducers: (builder) => {
builder
.addCase(login.fulfilled, (state, action) => {
state.authorization = action.payload.authorization;
state.user = action.payload.user;
})
.addCase(fetchCurrentUser.fulfilled, (state, action) => {
state.user = action.payload;
});
},
});
export const { logout } = meSlice.actions;
export default meSlice.reducer;
// Selectors
export const selectAuthorization = (state: RootState) => state.iam.me.authorization;
export const selectCurrentUser = (state: RootState) => state.iam.me.user;
createAppAsyncThunk
永遠使用 createAppAsyncThunk 取代 Redux Toolkit 的 createAsyncThunk。這確保錯誤物件能正確傳遞,支援 prompt.error(error) 自動國際化。
日期處理
問題
Redux 要求所有狀態都是可序列化的,但 API 回傳的 Date 物件不可序列化。
解決方案
API Response (Date 物件)
↓
dateSerializerMiddleware(序列化為 ISO 字串)
↓
Redux State (ISO 字串)
↓
useAppSelector(反序列化為 Date 物件)
↓
Component (Date 物件) ✅
使用方式
無需手動處理,元件直接使用 Date 物件:
function OrderCard() {
const order = useAppSelector(selectOrder);
// order.createdAt 自動是 Date 物件
return (
<div>
<span>{order.createdAt.toLocaleDateString()}</span>
</div>
);
}
序列化工具
// src/store/serializers.ts
// 將 Date 轉換為 ISO 字串(遞迴處理)
export function serializeDates<T>(data: T): T;
// 將 ISO 字串轉換為 Date(遞迴處理)
export function deserializeDates<T>(data: T): T;
中介層
日期序列化中介層
自動將 Action payload 中的 Date 物件轉換為 ISO 字串:
// src/store/date-serializer-middleware.ts
import { Middleware } from '@reduxjs/toolkit';
import { serializeDates } from './serializers';
export const dateSerializerMiddleware: Middleware = () => (next) => (action) => {
if (action.payload !== undefined) {
action.payload = serializeDates(action.payload);
}
return next(action);
};
認證儲存中介層
監聽登入/登出,同步 Token 到 Storage:
// src/store/auth-storage-middleware.ts
import { Middleware } from '@reduxjs/toolkit';
export const authStorageMiddleware: Middleware = (store) => (next) => (action) => {
const result = next(action);
if (action.type === 'iam/login/fulfilled') {
const { authorization, remember } = action.payload;
const storage = remember ? localStorage : sessionStorage;
storage.setItem('access_token', authorization.access_token);
storage.setItem('refresh_token', authorization.refresh_token);
}
if (action.type === 'iam/me/logout') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
sessionStorage.removeItem('access_token');
sessionStorage.removeItem('refresh_token');
}
return result;
};
常見模式
Finder Slice(搜尋/篩選狀態)
用於列表頁的搜尋、篩選、排序狀態:
interface FinderState {
searchTerm: string;
filters: FilterType;
sorting: SortingState;
columnOrder: ColumnOrderState;
columnVisibility: VisibilityState;
scrollTop: number;
}
特點:
- 與 TanStack Table 整合
- 支援頁面離開後恢復狀態
- 搜尋/篩選變更時重置滾動位置
Draft Slice(草稿管理)
用於表單草稿:
interface Draft {
id: string;
changes: Partial<FormData>;
createdAt: Date;
updatedAt: Date;
mode: 'create' | 'edit';
sourceId?: string; // 編輯模式:原始記錄 ID
}
interface DraftState {
drafts: Draft[];
}
Actions:
saveDraft- 新增/更新草稿deleteDraft- 刪除草稿deleteEditDraftBySourceId- 刪除特定記錄的編輯草稿
Pinned Slice(釘選記錄)
用於快速存取常用記錄:
interface PinnedRecord {
id: string;
type: 'customer' | 'order' | 'product';
label: string;
pinnedAt: Date;
}
Memoized Selectors:
import { createSelector } from '@reduxjs/toolkit';
export const selectPinnedCustomers = createSelector(
[(state: RootState) => state.pinned.records],
(records) => records.filter((r) => r.type === 'customer')
);
組織模式
Feature Index
每個 feature 目錄有一個 index.ts 匯出所有內容:
// src/features/iam/index.ts
export const iamReducers = {
me: meReducer,
};
// Re-export actions and selectors
export { login, logout, fetchCurrentUser } from './me-slice';
export { selectAuthorization, selectCurrentUser } from './me-slice';
Root Reducers
組合所有 feature reducers:
// src/features/root-reducers.ts
import { combineReducers } from '@reduxjs/toolkit';
import { iamReducers } from './iam';
import { salesReducers } from './sales';
import { crmReducers } from './crm';
import { productReducers } from './product';
export const rootReducers = combineReducers({
iam: combineReducers(iamReducers),
sales: combineReducers(salesReducers),
crm: combineReducers(crmReducers),
product: combineReducers(productReducers),
});
狀態持久化
啟動時恢復
// src/store/index.ts
function loadStateFromStorage(): Partial<RootState> | undefined {
const accessToken = localStorage.getItem('access_token')
|| sessionStorage.getItem('access_token');
const refreshToken = localStorage.getItem('refresh_token')
|| sessionStorage.getItem('refresh_token');
if (accessToken && refreshToken) {
return {
iam: {
me: {
authorization: {
access_token: accessToken,
refresh_token: refreshToken,
token_type: 'Bearer',
expires_in: 900,
},
user: null, // 稍後由 fetchCurrentUser 恢復
},
},
};
}
return undefined;
}
完整恢復流程
最佳實踐
- 使用 createAppAsyncThunk - 確保錯誤處理正確
- 善用 Selector - 使用
createSelector進行記憶化 - Feature 模組化 - 每個 feature 獨立目錄,有明確的 index.ts
- 日期處理透明化 - 透過 middleware 和 useAppSelector 自動處理
- Token 安全儲存 - 根據「記住我」選擇 localStorage 或 sessionStorage