狀態管理
AppFuse Web 使用 Redux Toolkit 進行全域狀態管理,提供型別安全的 Store、自動日期與檔案序列化和認證持久化。
架構概覽
Store 結構
src/
├── store/
│ ├── index.ts # Store 配置、型別、Hooks
│ ├── hooks.ts # 基本型別 hooks(未含日期反序列化)
│ ├── serializers.ts # 日期序列化工具
│ ├── create-app-async-thunk.ts # 自訂 AsyncThunk 工廠
│ ├── date-serializer-middleware.ts
│ ├── file-serializer-middleware.ts
│ └── auth-storage-middleware.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
├── pinned/ # 釘選記錄
└── 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 { fileSerializerMiddleware } from './file-serializer-middleware'
import { authStorageMiddleware, getAccessToken, getRefreshToken } from './auth-storage-middleware'
import { deserializeDates } from './serializers'
// 從 rootReducers 推導 RootState 型別(避免循環引用)
export type RootState = ReturnType<typeof rootReducers>
const store = configureStore({
reducer: rootReducers,
preloadedState: loadStateFromLocalStorage(),
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActionPaths: ['meta.arg', 'meta.baseQueryMeta'],
isSerializable: (value: unknown) => {
// Date、File、ErrorResponse 物件允許通過
if (value instanceof Date) return true
if (value instanceof File) return true
if (value instanceof ErrorResponse) return true
// 其他使用預設邏輯
return (
value === undefined ||
value === null ||
typeof value === 'string' ||
typeof value === 'boolean' ||
typeof value === 'number' ||
Array.isArray(value) ||
(typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype)
)
},
},
}).concat(fileSerializerMiddleware, dateSerializerMiddleware, authStorageMiddleware),
})
export type AppDispatch = typeof store.dispatch
export type AppStore = typeof store
自訂 Hooks
型別安全的 hooks 定義在 store/index.ts:
// src/store/index.ts
import { useDispatch, useSelector } from 'react-redux'
// 型別安全的 dispatch
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
// 自動反序列化日期的 selector
export const useAppSelector = <TSelected>(
selector: (state: RootState) => TSelected
): TSelected => {
const selected = useSelector.withTypes<RootState>()(selector)
return deserializeDates(selected)
}
:::info store/hooks.ts
store/hooks.ts 提供基本的型別 hooks(不含日期反序列化),主要供內部使用。
應用程式碼應從 @/store 匯入 useAppDispatch 和 useAppSelector,以獲得自動日期反序列化功能。
:::
在元件中使用
import { useAppDispatch, useAppSelector } from '@/store'
import { selectCurrentUser, logout } from '@/features/iam/me-slice'
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, type PayloadAction } from '@reduxjs/toolkit'
import { type RootState } from '@/store'
import { createAppAsyncThunk } from '@/store/create-app-async-thunk'
import { authService } from '@/services/iam/auth-service'
import type { LoginResponse } from '@/mocks/types'
import { type RoleName, Role } from '@/types/auth'
// 定義型別
export type Preferences = Record<string, string | number | boolean | Date>
export type Authorization = {
access_token: string
token_type: string
expires_in: number
refresh_token: string
}
export type User = {
id: string
email: string
name: string
/** 主要角色 (ROLE_XXX 格式) */
role: RoleName
/** 角色陣列 (Spring Security 格式) */
roles: string[]
}
export type Applet = {
text: string
icon?: string
image?: string
id: string
badge?: number | string
category: string
description?: string
disabled?: boolean
locked?: boolean
}
type State = {
authorization: Authorization | null
user: User | null
preferences: Preferences | null
applets: Applet[]
}
const initialState: State = {
authorization: null,
user: null,
preferences: null,
applets: [],
}
// 非同步 Thunk
export const login = createAppAsyncThunk(
'me/login',
async (credentials: { username: string; password: string; remember?: boolean }) => {
return await authService.login(
credentials.username,
credentials.password,
credentials.remember
)
}
)
export const fetchCurrentUser = createAppAsyncThunk(
'me/fetchCurrentUser',
async (accessToken: string) => {
const user = await authService.me(accessToken)
return user as User
}
)
// Slice
const slice = createSlice({
name: 'me',
initialState,
reducers: {
setAuthorization(state, action: PayloadAction<Authorization>) {
state.authorization = action.payload
},
setPreferences(state, action: PayloadAction<Preferences>) {
state.preferences = action.payload
},
setApplets(state, action: PayloadAction<Applet[]>) {
state.applets = action.payload
},
logout(state) {
state.authorization = null
state.user = null
state.preferences = null
state.applets = []
},
},
extraReducers: (builder) => {
builder
.addCase(login.fulfilled, (state, action: PayloadAction<LoginResponse>) => {
state.authorization = {
access_token: action.payload.accessToken,
token_type: 'Bearer',
expires_in: 15 * 60,
refresh_token: action.payload.refreshToken,
}
const user = action.payload.user
const roles = user.roles ?? []
state.user = {
id: user.id,
email: user.email,
name: user.name,
role: getPrimaryRole(roles),
roles,
}
})
.addCase(fetchCurrentUser.fulfilled, (state, action) => {
const user = action.payload as Record<string, unknown>
const roles = (user.roles ?? []) as string[]
state.user = {
id: user.id as string,
email: user.email as string,
name: user.name as string,
role: getPrimaryRole(roles),
roles,
}
})
},
})
export const { setAuthorization, setPreferences, setApplets, logout } = slice.actions
export const meReducer = slice.reducer
// Selectors
export const selectAuthorization = (state: RootState) => state.iam.me.authorization
export const selectCurrentUser = (state: RootState) => state.iam.me.user
export const selectPreferences = (state: RootState) => state.iam.me.preferences
export const selectApplets = (state: RootState) => state.iam.me.applets
:::warning 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
// 建立型別安全的序列化器
export function createSerializer<TInput, TOutput = TInput>()
// 建立型別安全的反序列化器
export function createDeserializer<TInput, TOutput = TInput>()
中介層
Store 串接三個中介層,順序為 fileSerializerMiddleware → dateSerializerMiddleware → authStorageMiddleware。
檔案序列化中介層
自動將 Action payload 中的 File 物件轉換為可序列化的 FileProxy 格式:
// src/store/file-serializer-middleware.ts
import type { Middleware } from '@reduxjs/toolkit'
import { fileToFileProxy } from '@appfuse/appfuse-web/utils'
export const fileSerializerMiddleware: Middleware = () => (next) => (action) => {
const serializedAction =
action && typeof action === 'object' && 'payload' in action
? { ...action, payload: serializeFiles(action.payload) }
: action
return next(serializedAction)
}
日期序列化中介層
自動將 Action payload 中的 Date 物件轉換為 ISO 字串:
// src/store/date-serializer-middleware.ts
import type { Middleware } from '@reduxjs/toolkit'
import { serializeDates } from './serializers'
export const dateSerializerMiddleware: Middleware = () => (next) => (action) => {
if (
typeof action === 'object' &&
action !== null &&
'payload' in action &&
action.payload &&
typeof action.payload === 'object'
) {
return next({
...action,
payload: serializeDates(action.payload),
})
}
return next(action)
}
認證儲存中介層
監聽登入/登出,同步 Token 到 Storage:
// src/store/auth-storage-middleware.ts
import type { Middleware } from '@reduxjs/toolkit'
export const authStorageMiddleware: Middleware = (storeAPI) => (next) => (action) => {
const result = next(action)
if (typeof action === 'object' && action !== null && 'type' in action) {
if (action.type === 'me/login/fulfilled') {
// 從 action.meta.arg 取得 remember 選項
const remember = action.meta?.arg?.remember ?? false
const storage = remember ? localStorage : sessionStorage
// 從已更新的 state 讀取 authorization
const state = storeAPI.getState()
const authorization = state.iam.me.authorization
if (authorization?.access_token) {
// 清除另一種 storage 的舊 token
const otherStorage = remember ? sessionStorage : localStorage
otherStorage.removeItem(ACCESS_TOKEN_KEY)
otherStorage.removeItem(REFRESH_TOKEN_KEY)
// 儲存到選定的 storage
storage.setItem(ACCESS_TOKEN_KEY, authorization.access_token)
storage.setItem(REFRESH_TOKEN_KEY, authorization.refresh_token)
localStorage.setItem(AUTH_STORAGE_TYPE_KEY, remember ? 'local' : 'session')
}
} else if (action.type === 'me/logout') {
// 清除兩種 storage 的 token
localStorage.removeItem(ACCESS_TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(AUTH_STORAGE_TYPE_KEY)
sessionStorage.removeItem(ACCESS_TOKEN_KEY)
sessionStorage.removeItem(REFRESH_TOKEN_KEY)
}
}
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 匯出 reducers、actions 和 selectors:
// src/features/iam/index.ts
import { meReducer } from './me-slice.ts'
export const iamReducers = {
me: meReducer,
}
// Re-export actions and selectors
export {
setAuthorization,
setPreferences,
setApplets,
selectAuthorization,
selectPreferences,
selectApplets,
} from './me-slice.ts'
// Re-export types
export type { Authorization, Preferences, Applet } from './me-slice.ts'
:::info 非同步 Thunk 匯出
login、fetchCurrentUser、logout、selectCurrentUser 等未從 iam/index.ts 匯出,需直接從 ./me-slice 匯入。
:::
Root Reducers
組合所有 feature reducers:
// src/features/root-reducers.ts
import { iamReducers } from './iam'
import { appReducers } from './app'
import { salesReducers } from './sales'
import { crmReducers } from './crm'
import { productReducers } from './product'
import { pinnedReducer } from './pinned'
import { combineReducers } from '@reduxjs/toolkit'
export const rootReducers = combineReducers({
iam: combineReducers(iamReducers),
app: combineReducers(appReducers),
sales: combineReducers(salesReducers),
crm: combineReducers(crmReducers),
product: combineReducers(productReducers),
pinned: pinnedReducer,
})
狀態持久化
啟動時恢復
// src/store/index.ts
function loadStateFromLocalStorage(): Partial<RootState> | undefined {
try {
const accessToken = getAccessToken()
const refreshToken = getRefreshToken()
const preloadedState: Partial<RootState> = {}
if (accessToken && refreshToken) {
preloadedState.iam = {
me: {
authorization: {
access_token: accessToken,
refresh_token: refreshToken,
token_type: 'Bearer',
expires_in: 15 * 60, // 15 分鐘
},
user: null,
preferences: null,
applets: [],
},
}
}
return Object.keys(preloadedState).length > 0 ? preloadedState : undefined
} catch (error) {
console.error('Failed to load state from localStorage:', error)
}
return undefined
}
完整恢復流程
最佳實踐
- 使用 createAppAsyncThunk - 確保錯誤處理正確
- 善用 Selector - 使用
createSelector進行記憶化 - Feature 模組化 - 每個 feature 獨立目錄,有明確的 index.ts
- 日期處理透明化 - 透過 middleware 和 useAppSelector 自動處理
- 檔案序列化 - File 物件自動轉換為 FileProxy,支援草稿儲存
- Token 安全儲存 - 根據「記住我」選擇 localStorage 或 sessionStorage