跳至主要内容

狀態管理

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;
}

完整恢復流程

最佳實踐

  1. 使用 createAppAsyncThunk - 確保錯誤處理正確
  2. 善用 Selector - 使用 createSelector 進行記憶化
  3. Feature 模組化 - 每個 feature 獨立目錄,有明確的 index.ts
  4. 日期處理透明化 - 透過 middleware 和 useAppSelector 自動處理
  5. Token 安全儲存 - 根據「記住我」選擇 localStorage 或 sessionStorage

下一步