跳至主要内容

狀態管理

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 匯入 useAppDispatchuseAppSelector,以獲得自動日期反序列化功能。 :::

在元件中使用

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 串接三個中介層,順序為 fileSerializerMiddlewaredateSerializerMiddlewareauthStorageMiddleware

檔案序列化中介層

自動將 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 匯出 loginfetchCurrentUserlogoutselectCurrentUser 等未從 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
}

完整恢復流程

最佳實踐

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

下一步