在 React 開發中,狀態管理一直是一個熱門話題。從早期的 Redux 到現在的 Zustand、Jotai 等現代解決方案,開發者們總是在尋找最適合的狀態管理方式。但在選擇工具之前,我們需要先理解一個根本問題:為什麼 React 需要外部狀態管理?

React 初期的單向資料流設計

React 的設計哲學建立在單向資料流的基礎上。這意味著:

  1. 資料只能從父組件流向子組件
  2. 子組件不能直接修改父組件的狀態
    • 不能這樣做props.count = 10 直接修改 props 值
    • 只能這樣做:通過回調函數 props.onIncrement() 通知父組件更新
  3. 狀態更新必須通過 setState 或 useState

這種設計帶來了很多好處:

  • 資料流向清晰可預測

    • 例如:用戶登入狀態 App → Header → UserMenu,資料只會從上往下傳遞
    • 狀態更新只會從子組件通過回調函數向上傳遞到父組件
  • 組件間的依賴關係明確

    • 例如:UserMenu 依賴 Header 傳遞的 useronLogout props
    • 可以清楚看到哪些組件需要哪些資料
  • 容易進行除錯和測試

    • 例如:測試 UserMenu 時,只需要傳入 { user: mockUser, onLogout: mockFn }
    • 除錯時可以追蹤用戶登入狀態從哪個父組件傳入,登出回調函數會觸發哪個父組件的更新
// 典型的單向資料流 - 用戶登入情境
function App() {
  const [user, setUser] = useState(null);
 
  return (
    <div>
      <Header user={user} onLogout={() => setUser(null)} />
    </div>
  );
}
 
function Header({ user, onLogout }) {
  return (
    <header>
      <UserMenu user={user} onLogout={onLogout} />
    </header>
  );
}
 
function UserMenu({ user, onLogout }) {
  return (
    <div>
      {user ? (
        <button onClick={onLogout}>登出 {user.name}</button>
      ) : (
        <span>請先登入</span>
      )}
    </div>
  );
}

useState 與 props 傳遞的侷限

隨著應用程式變得複雜,單純使用 useState 和 props 傳遞會遇到以下問題:

1. 狀態分散在各個組件中

當每個組件都管理自己的狀態時,會導致:

  • 難以追蹤:不知道某個狀態在哪個組件中
  • 重複邏輯:多個組件可能有相似的狀態管理邏輯
  • 同步困難:相關狀態分散在不同組件,難以保持一致性
// 每個組件都有自己的狀態 - 造成問題的範例
function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState("light");
  const [notifications, setNotifications] = useState([]);
  // 問題:不知道用戶偏好設定在哪個組件中
}
 
function Header() {
  const [isMenuOpen, setIsMenuOpen] = useState(false);
  const [user, setUser] = useState(null); // 重複:App 也有 user 狀態
  // 問題:重複的用戶狀態管理邏輯
}
 
function Sidebar() {
  const [isCollapsed, setIsCollapsed] = useState(false);
  const [theme, setTheme] = useState("light"); // 重複:App 也有 theme 狀態
  // 問題:主題狀態分散,難以保持同步
}
 
function UserMenu() {
  const [user, setUser] = useState(null); // 又一個重複的 user 狀態
  // 問題:三個組件都有 user,但可能不同步
}

2. 狀態同步困難

當多個組件需要共享同一個狀態時,需要將狀態提升到共同的父組件,這會導致:

  • 父組件變得臃腫:所有共享狀態都集中在父組件,導致組件過於龐大

    • 例如:App 組件需要管理 userthemenotificationscartfilters 等 20+ 個狀態
    • 結果:一個組件有 500+ 行代碼,難以閱讀和理解
  • 狀態更新邏輯分散:同一個狀態的更新邏輯可能散落在多個子組件中

    • 例如:user 狀態的更新邏輯在 LoginFormUserProfileHeader 等 5 個組件中都有
    • 結果:修改用戶登入邏輯需要同時修改多個文件
  • 難以維護和測試:修改一個狀態需要同時考慮多個組件的影響

    • 例如:你想把深色模式從 dark 改成 night,但不知道哪些組件在使用這個狀態
    • 結果:改了 App 組件的 theme 狀態,結果 Header 的按鈕顏色變了,Sidebar 的背景沒變,Modal 的邊框消失了
    • 問題:一個簡單的改名,卻要修復 3 個不同的 bug

prop drilling 問題實例

Prop drilling 是指為了將資料傳遞到深層嵌套的組件,必須通過中間組件一層層傳遞 props 的問題。

// 經典的 prop drilling 範例
function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState("light");
 
  return (
    <div>
      <Header user={user} theme={theme} />
      <Main user={user} theme={theme} />
    </div>
  );
}
 
function Header({ user, theme }) {
  return (
    <header>
      <Navigation user={user} theme={theme} />
    </header>
  );
}
 
function Navigation({ user, theme }) {
  return (
    <nav>
      <UserMenu user={user} theme={theme} />
    </nav>
  );
}
 
function UserMenu({ user, theme }) {
  return (
    <div>
      <Avatar user={user} />
      <ThemeToggle theme={theme} />
    </div>
  );
}

Prop drilling 的問題:

  1. 中間組件被迫接收不需要的 props
  2. 代碼變得冗長且難以閱讀
  3. 修改 props 結構需要更新多個組件
  4. 組件耦合度增加

Context API 的誕生與缺陷

為了解決 prop drilling 問題,React 16.3 引入了 Context API:

// 使用 Context API 解決 prop drilling
const UserContext = createContext();
const ThemeContext = createContext();
 
function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState("light");
 
  return (
    <UserContext.Provider value={{ user, setUser }}>
      <ThemeContext.Provider value={{ theme, setTheme }}>
        <Header />
        <Main />
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}
 
function UserMenu() {
  const { user } = useContext(UserContext);
  const { theme } = useContext(ThemeContext);
 
  return (
    <div>
      <Avatar user={user} />
      <ThemeToggle theme={theme} />
    </div>
  );
}

Context API 的優點:

  • 解決了 prop drilling 問題

    • 例如:UserMenu 不再需要通過 HeaderNavigation 傳遞 useronLogout
    • 結果:中間組件 HeaderNavigation 不再需要接收不需要的 props
  • 組件可以直接存取需要的狀態

    • 例如:UserMenu 可以直接使用 useContext(UserContext) 獲取用戶資料
    • 結果:組件只依賴自己真正需要的資料,依賴關係更清晰
  • 代碼更簡潔

    • 例如:原本需要 3 層 props 傳遞,現在只需要 1 行 useContext
    • 結果:代碼行數減少,可讀性提升

Context API 的缺陷:

  1. 性能問題:任何 Context 值改變都會重新渲染所有消費者

    • 例如:AppContext 包含 userthemenotifications,當 notifications 更新時
    • 結果:HeaderSidebarMain 等所有使用 Context 的組件都會重新渲染
    • 問題:即使組件只關心 usertheme,也會因為 notifications 更新而重新渲染
  2. 難以除錯:狀態更新來源不明確

    • 例如:發現 user 狀態變成 null,但不知道是哪個組件調用了 setUser(null)
    • 結果:需要在 10+ 個組件中搜尋 setUser 的調用,才能找到問題源頭
    • 問題:除錯時間大幅增加,容易遺漏某些調用點
  3. 缺乏時間旅行除錯:無法追蹤狀態變化歷史

    • 例如:用戶報告「購物車突然清空了」,但你只能看到當前空的購物車狀態
    • 結果:無法重現問題發生的過程,不知道是哪一步操作導致的
    • 問題:無法回到問題發生前的狀態進行分析

時間旅行除錯的實際例子

時間旅行除錯是指能夠回到應用程式的任何一個歷史狀態,就像時光機一樣。讓我們看看具體的差異:

// Context API - 無法追蹤狀態變化歷史
const AppContext = createContext();
 
function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("");
 
  // 當狀態改變時,你只能看到當前狀態
  // 無法知道:count 是從 0 → 5 → 3 → 8 這樣變化的
  const value = { count, setCount, name, setName };
 
  return (
    <AppContext.Provider value={value}>
      <Counter />
      <NameInput />
    </AppContext.Provider>
  );
}

問題:如果用戶報告了一個 bug,你只能看到最終的狀態,無法重現問題發生的過程。

// 使用 Redux 時,可以追蹤每個狀態變化
const store = createStore(reducer);
 
// 用戶的操作會產生這樣的歷史記錄:
// Action 1: { type: 'INCREMENT', payload: 1 } → count: 1
// Action 2: { type: 'INCREMENT', payload: 1 } → count: 2
// Action 3: { type: 'SET_NAME', payload: 'John' } → name: 'John'
// Action 4: { type: 'INCREMENT', payload: 1 } → count: 3
// Action 5: { type: 'DECREMENT', payload: 1 } → count: 2

實際使用場景

  • 重現 Bug:可以回到崩潰前的每一步,重現問題
  • 理解複雜狀態變化:追蹤購物車、表單等複雜狀態的變化過程
  • 測試不同操作順序:驗證不同操作順序是否會產生不同結果
// 現代狀態管理庫的解決方案
import { create } from "zustand";
import { devtools } from "zustand/middleware";
 
const useStore = create(
  devtools(
    (set) => ({
      count: 0,
      name: "",
      increment: () => set((state) => ({ count: state.count + 1 })),
      setName: (name) => set({ name }),
    }),
    { name: "app-store" } // DevTools 中顯示的名稱
  )
);
 
// 現在你可以在瀏覽器 DevTools 中看到:
// - 每個狀態變化的歷史
// - 觸發的 action 名稱
// - 狀態變化的時間戳
// - 可以回到任何歷史狀態
// Context 性能問題範例
const AppContext = createContext();
 
function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState("light");
  const [notifications, setNotifications] = useState([]);
 
  // 任何一個狀態改變,所有消費者都會重新渲染
  const value = {
    user,
    setUser,
    theme,
    setTheme,
    notifications,
    setNotifications,
  };
 
  return (
    <AppContext.Provider value={value}>
      <Header />
      <Main />
      <Sidebar />
    </AppContext.Provider>
  );
}

狀態管理出現的根本原因

基於上述問題,外部狀態管理庫應運而生,它們解決了:

1. 狀態集中管理

// 使用 Zustand 集中管理狀態
import { create } from "zustand";
 
const useStore = create((set) => ({
  user: null,
  theme: "light",
  notifications: [],
  setUser: (user) => set({ user }),
  setTheme: (theme) => set({ theme }),
  addNotification: (notification) =>
    set((state) => ({
      notifications: [...state.notifications, notification],
    })),
}));

2. 性能優化

  • 只有相關組件會在狀態改變時重新渲染
  • 支援選擇性訂閱
  • 內建 memoization

3. 開發者體驗

  • 時間旅行除錯
  • 狀態變化追蹤
  • 更好的 TypeScript 支援

4. 可預測的狀態更新

  • 明確的 action 和 reducer 模式
  • 不可變的狀態更新
  • 更容易測試和除錯

總結

React 的單向資料流設計雖然簡單直觀,但在複雜應用中會遇到 prop drilling 和狀態同步等問題。Context API 部分解決了這些問題,但在性能和開發者體驗方面仍有不足。

外部狀態管理庫的出現,正是為了解決這些根本性問題:

  • 集中管理複雜的應用狀態
  • 優化性能,避免不必要的重新渲染
  • 提升開發者體驗,提供更好的除錯和測試工具
  • 保持可預測性,讓狀態更新更加明確和可控