Frontend 狀態管理詳解#

本專案使用 Zustand 管理全域狀態,相較於 Angular 的 NgRx 簡單很多。

Zustand vs NgRx 對照#

概念NgRxZustand
Store 定義Module + Reducer + Actions單一 create 函數
狀態更新dispatch(action)直接呼叫方法
取得狀態select + subscribehook 直接取用
程式碼量多檔案、大量 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) => ({
    // ...
  }))
);

下一步#

理解狀態管理後,接下來看 資料流程範例 了解完整的前後端互動流程。