Frontend 狀態管理詳解#
本專案使用 Zustand 管理全域狀態,相較於 Angular 的 NgRx 簡單很多。
Zustand vs NgRx 對照#
| 概念 | NgRx | Zustand |
|---|---|---|
| Store 定義 | Module + Reducer + Actions | 單一 create 函數 |
| 狀態更新 | dispatch(action) | 直接呼叫方法 |
| 取得狀態 | select + subscribe | hook 直接取用 |
| 程式碼量 | 多檔案、大量 boilerplate | 單檔案、極少 boilerplate |
檔案位置#
frontend/src/store/
├── useUserStore.ts # 用戶認證狀態
└── useJourneyStore.ts # 學習旅程狀態基本用法#
1. 建立 Store#
// store/useUserStore.ts
import { create } from 'zustand';
// 定義狀態型別
interface User {
id: number;
name: string;
email: string;
level: number;
exp: number;
avatarUrl: string;
role: string;
}
interface UserState {
// 狀態
user: User | null;
isAuthenticated: boolean;
// 方法
setUser: (user: User | null) => void;
logout: () => void;
}
// 建立 Store
export const useUserStore = create<UserState>((set) => ({
// 初始狀態
user: null,
isAuthenticated: false,
// 更新方法
setUser: (user) => set({
user,
isAuthenticated: !!user
}),
logout: () => set({
user: null,
isAuthenticated: false
}),
}));NgRx 對照:
// NgRx 需要多個檔案
// user.actions.ts
export const setUser = createAction('[User] Set User', props<{ user: User }>());
export const logout = createAction('[User] Logout');
// user.reducer.ts
export const userReducer = createReducer(
initialState,
on(setUser, (state, { user }) => ({ ...state, user, isAuthenticated: true })),
on(logout, () => initialState)
);
// user.selectors.ts
export const selectUser = createSelector(selectUserState, state => state.user);2. 使用 Store#
'use client';
import { useUserStore } from '@/store/useUserStore';
export function UserProfile() {
// 取得狀態和方法
const { user, isAuthenticated, logout } = useUserStore();
// 或只取需要的部分(效能優化)
const user = useUserStore((state) => state.user);
const logout = useUserStore((state) => state.logout);
if (!isAuthenticated) {
return <p>請先登入</p>;
}
return (
<div>
<p>歡迎,{user?.name}!</p>
<p>等級:Lv {user?.level}</p>
<button onClick={logout}>登出</button>
</div>
);
}NgRx 對照:
// Angular Component
export class UserProfileComponent {
user$ = this.store.select(selectUser);
isAuthenticated$ = this.store.select(selectIsAuthenticated);
constructor(private store: Store) {}
logout() {
this.store.dispatch(logout());
}
}實際 Store 範例#
useUserStore - 用戶狀態#
// store/useUserStore.ts
import { create } from 'zustand';
import { User, UserPermissions } from '@/types';
interface UserState {
user: User | null;
permissions: UserPermissions;
isAuthenticated: boolean;
setUser: (user: User | null) => void;
setPermissions: (permissions: UserPermissions) => void;
logout: () => void;
}
const defaultPermissions: UserPermissions = {
isLord: false,
isAdventurer: false,
isTrialist: false,
};
export const useUserStore = create<UserState>((set) => ({
user: null,
permissions: defaultPermissions,
isAuthenticated: false,
setUser: (user) => set({
user,
isAuthenticated: !!user
}),
setPermissions: (permissions) => set({ permissions }),
logout: () => set({
user: null,
permissions: defaultPermissions,
isAuthenticated: false,
}),
}));useJourneyStore - 學習旅程狀態#
// store/useJourneyStore.ts
import { create } from 'zustand';
interface JourneyState {
currentJourneyId: number | null;
currentCourseFeatures: string[];
setCurrentJourneyId: (id: number | null) => void;
setCurrentCourseFeatures: (features: string[]) => void;
}
export const useJourneyStore = create<JourneyState>((set) => ({
currentJourneyId: null,
currentCourseFeatures: [],
setCurrentJourneyId: (id) => set({ currentJourneyId: id }),
setCurrentCourseFeatures: (features) => set({ currentCourseFeatures: features }),
}));常見使用情境#
登入流程#
// app/sign-in/page.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useUserStore } from '@/store/useUserStore';
export default function SignInPage() {
const router = useRouter();
const setUser = useUserStore((state) => state.setUser);
const handleLogin = async (email: string, password: string) => {
// 1. 呼叫登入 API
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('登入失敗');
}
const data = await response.json();
// 2. 儲存 Token
localStorage.setItem('token', data.token);
// 3. 更新全域狀態
setUser({
id: data.userId,
name: data.name,
email: data.email,
level: data.level,
exp: data.exp,
avatarUrl: data.avatarUrl,
role: data.role,
});
// 4. 導向首頁
router.push('/');
};
// ...
}在多個元件中使用#
// components/layout/Sidebar.tsx
export function Sidebar() {
const { user, logout } = useUserStore();
return (
<aside>
{user && (
<div>
<span>{user.name}</span>
<button onClick={logout}>登出</button>
</div>
)}
</aside>
);
}
// components/layout/TopBar.tsx
export function TopBar() {
const user = useUserStore((state) => state.user);
return (
<header>
{user ? (
<span>Lv {user.level}</span>
) : (
<Link href="/sign-in">登入</Link>
)}
</header>
);
}初始化認證狀態#
// components/AuthProvider.tsx
'use client';
import { useEffect } from 'react';
import { useUserStore } from '@/store/useUserStore';
export function AuthProvider({ children }: { children: React.ReactNode }) {
const setUser = useUserStore((state) => state.setUser);
useEffect(() => {
// 頁面載入時檢查 Token 並載入用戶資料
const token = localStorage.getItem('token');
if (token) {
fetch('/api/users/me', {
headers: { 'Authorization': `Bearer ${token}` },
})
.then((res) => res.json())
.then((user) => setUser(user))
.catch(() => {
localStorage.removeItem('token');
setUser(null);
});
}
}, [setUser]);
return <>{children}</>;
}
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}進階用法#
Selector - 衍生狀態#
// 從 user 衍生出是否為老師
const isTeacher = useUserStore((state) => state.user?.role === 'TEACHER');
// 計算經驗值進度
const expProgress = useUserStore((state) => {
if (!state.user) return 0;
const { exp, level } = state.user;
// 計算邏輯...
return progress;
});在 Store 外使用#
// 直接取得狀態(非 React 環境)
const user = useUserStore.getState().user;
// 訂閱變化
useUserStore.subscribe((state) => {
console.log('User changed:', state.user);
});持久化(可選)#
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useUserStore = create(
persist<UserState>(
(set) => ({
user: null,
setUser: (user) => set({ user }),
}),
{
name: 'user-storage', // localStorage key
}
)
);與 Angular Service 對比#
Angular Service(傳統做法):
@Injectable({ providedIn: 'root' })
export class UserService {
private userSubject = new BehaviorSubject<User | null>(null);
user$ = this.userSubject.asObservable();
setUser(user: User | null) {
this.userSubject.next(user);
}
getUser() {
return this.userSubject.getValue();
}
}
// 使用
constructor(private userService: UserService) {}
this.userService.user$.subscribe(user => this.user = user);Zustand:
// 定義
export const useUserStore = create<UserState>((set) => ({
user: null,
setUser: (user) => set({ user }),
}));
// 使用
const user = useUserStore((state) => state.user);
const setUser = useUserStore((state) => state.setUser);常見問題#
Q: 什麼時候用 Zustand vs useState?#
- useState: 元件內部狀態,不需要共享
- Zustand: 跨元件共享的全域狀態
Q: 會不會有效能問題?#
Zustand 有內建的 selector 優化,只有使用到的狀態變化才會觸發重新渲染:
// 好:只在 user 變化時重新渲染
const user = useUserStore((state) => state.user);
// 不好:任何狀態變化都會重新渲染
const store = useUserStore();Q: 需要 DevTools 嗎?#
Zustand 支援 Redux DevTools:
import { devtools } from 'zustand/middleware';
export const useUserStore = create(
devtools<UserState>((set) => ({
// ...
}))
);下一步#
理解狀態管理後,接下來看 資料流程範例 了解完整的前後端互動流程。