EP.21Web 開發系列

Context API:告別 Props Drilling
跨元件共享狀態的正確方式

createContext、useContext、ThemeContext 實戰、何時不該用 Context — 從「到處傳 props」的地獄中解脫,學會讓資料在元件樹中「自由流動」。

Joseph Chen 2026 13 min read Context · useContext · State Management

「App 傳 user 給 Layout,Layout 傳給 Sidebar,Sidebar 傳給 UserProfile…… 但 Layout 和 Sidebar 根本不用 user,它們只是「中間人」。」

這就是 Props Drilling——資料不得不穿過一層又一層它根本不需要的元件。 這篇教你用 Context API 解決這個問題,同時說清楚 Context 的使用邊界, 讓你不會把它用在它不擅長的地方。

Props Drilling 的痛苦

假設你有這樣的元件樹:UserProfile 需要 user 資料,但它藏在樹的很深處。

// 元件樹結構

App

└── Layout ← 不需要 user,但被迫傳遞

└── Sidebar ← 不需要 user,但被迫傳遞

└── UserProfile ← 真正需要 user 的地方

為了讓 UserProfile 拿到 user,你必須讓每一層都接收並往下傳:

tsx
// App.tsx — 資料的源頭
function App() {
  const [user, setUser] = useState({ name: 'Joseph', email: 'fg6ts15@gmail.com' });

  // 把 user 傳給 Layout(但 Layout 其實不用它!)
  return <Layout user={user} />;
}

// Layout.tsx — 中間人,自己不用 user,但必須接收並轉傳
function Layout({ user }: { user: User }) {
  return (
    <div>
      <main>...</main>
      {/* 繼續往下傳給 Sidebar */}
      <Sidebar user={user} />
    </div>
  );
}

// Sidebar.tsx — 又是中間人
function Sidebar({ user }: { user: User }) {
  return (
    <nav>
      {/* 終於傳到真正需要的地方 */}
      <UserProfile user={user} />
    </nav>
  );
}

// UserProfile.tsx — 真正用到 user 的元件
function UserProfile({ user }: { user: User }) {
  return <div>{user.name}</div>;
}

Props Drilling 的問題(三個層次)

  • 1.中間元件被迫接受它不需要的 props:Layout 和 Sidebar 的 props 介面被污染, 它們根本不在乎 user 是誰,卻必須在型別定義裡聲明。
  • 2.改 user 型別時,每一層都要改:假設你在 User 裡加了 avatar 欄位, Layout、Sidebar 的 PropTypes 都要更新,即使它們完全不用 avatar。
  • 3.難以重構元件:你想把 UserProfile 移到另一個位置, 但 props chain 斷掉了,整個資料流要重新梳理。

Context 的解法 — 全域的「廣播頻道」

生活類比:Context 像辦公室的內部廣播系統。 HR 透過廣播說「今天提早下班」,所有人都直接聽到, 不需要 HR 親自跑去每個人的座位告知, 也不需要主管把消息一層一層往下傳達。

每個元件就像一個員工——它只需要「打開廣播收聽」(useContext), 就能拿到資料,完全不依賴中間層傳遞。

Context 的使用分三個步驟:建立 Context、提供資料(Provider)、消費資料(useContext)。 加上一個最佳實踐:把 useContext 封裝成自訂 Hook:

tsx
// context/UserContext.tsx
import { createContext, useContext, useState } from 'react';

type User = {
  name: string;
  email: string;
};

type UserContextType = {
  user: User | null;
  setUser: (user: User | null) => void;
};

// 步驟 1:建立 Context
// undefined 作為預設值,讓我們在沒有 Provider 時能偵測到錯誤
const UserContext = createContext<UserContextType | undefined>(undefined);

// 步驟 2:建立 Provider 元件
// Provider 是「廣播站」,value 是要廣播的內容
export function UserProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
}

// 步驟 3:建立自訂 Hook 封裝存取(最佳實踐)
// 好處:不用每次都引入 UserContext,也確保一定在 Provider 內使用
export function useUser() {
  const context = useContext(UserContext);
  if (!context) {
    // 清楚的錯誤訊息,方便 debug
    throw new Error('useUser must be used within UserProvider');
  }
  return context;
}

有了這個 Context,原本四層都要傳遞 props 的問題消失了。 UserProfile 直接「收聽廣播」就好:

tsx
// App.tsx — 用 Provider 包住整個 app
function App() {
  return (
    <UserProvider>
      <Layout />  {/* ← Layout 不需要任何 props! */}
    </UserProvider>
  );
}

// Layout.tsx — 完全乾淨,不再是中間人
function Layout() {
  return (
    <div>
      <main>...</main>
      <Sidebar />  {/* ← Sidebar 也不需要任何 props! */}
    </div>
  );
}

// Sidebar.tsx — 同樣不需要傳任何東西
function Sidebar() {
  return (
    <nav>
      <UserProfile />
    </nav>
  );
}

// UserProfile.tsx — 直接從 Context 取資料
function UserProfile() {
  const { user } = useUser();  // ← 直接收聽廣播!

  if (!user) return <div>未登入</div>;
  return <div>{user.name}</div>;
}

完整 ThemeContext 實戰

Dark/Light mode 切換是最常見的 Context 使用案例,也是學習 Context 最好的入口。 讓我們寫一個完整的 ThemeContext,包含持久化到 localStorage:

tsx
// context/ThemeContext.tsx
import { createContext, useContext, useState, useEffect } from 'react';

type Theme = 'light' | 'dark';

type ThemeContextType = {
  theme: Theme;
  toggleTheme: () => void;
};

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');

  // 記住用戶偏好:app 啟動時從 localStorage 讀取
  useEffect(() => {
    const saved = localStorage.getItem('theme') as Theme;
    if (saved === 'light' || saved === 'dark') {
      setTheme(saved);
    }
  }, []);

  const toggleTheme = () => {
    setTheme(prev => {
      const next = prev === 'light' ? 'dark' : 'light';
      // 同時存進 localStorage,下次開啟 app 還記得
      localStorage.setItem('theme', next);
      return next;
    });
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {/* 根據 theme 加上 Tailwind 的 dark class */}
      <div className={theme === 'dark' ? 'dark' : ''}>
        {children}
      </div>
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be used within ThemeProvider');
  return context;
}

任何元件都可以直接使用 useTheme, 完全不需要 props 傳遞:

tsx
// 可以放在 app 的任何地方,不管層級多深
function ThemeToggleButton() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button
      onClick={toggleTheme}
      className="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700"
    >
      {theme === 'dark' ? '☀️ 切換淺色' : '🌙 切換深色'}
    </button>
  );
}

// Header 裡使用
function Header() {
  const { theme } = useTheme();

  return (
    <header className={theme === 'dark' ? 'bg-gray-900 text-white' : 'bg-white text-gray-900'}>
      <ThemeToggleButton />
    </header>
  );
}

// 任意深度的子元件都能直接拿到 theme
function DeepNestedComponent() {
  const { theme } = useTheme();
  return <div className={theme === 'dark' ? 'text-white' : 'text-gray-900'}>...</div>;
}

Tailwind Dark Mode 提醒:使用這個方式時,記得在 tailwind.config 設定darkMode: 'class', 這樣 Tailwind 才會根據 .dark class 套用深色樣式, 而不是根據系統設定。

多個 Context 的組合

真實 app 通常有多個 Context——主題、使用者、購物車…… 它們都需要在 layout 中組合起來。Provider 的巢狀順序有個慣例:

tsx
// app/layout.tsx
import { ThemeProvider } from '@/context/ThemeContext';
import { UserProvider } from '@/context/UserContext';
import { CartProvider } from '@/context/CartContext';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="zh-TW">
      <body>
        {/* 外層放最不常變動的 Context */}
        <ThemeProvider>
          {/* 中層放中等頻率更新的 */}
          <UserProvider>
            {/* 內層放較常更新的 */}
            <CartProvider>
              {children}
            </CartProvider>
          </UserProvider>
        </ThemeProvider>
      </body>
    </html>
  );
}

Provider 巢狀順序的邏輯

當一個 Context 的值改變,它的所有 children(包含其他 Provider)都可能重新渲染。 所以慣例是:

  • 外層:ThemeContext — 極少變動(用戶切換一次就固定了)
  • 中層:UserContext — 登入/登出才變(每個 session 一次)
  • 內層:CartContext — 相對頻繁(每次加入商品都會更新)

這樣 Cart 更新時,外層的 Theme 和 User Context 不受影響。

如果 Provider 巢狀太多,可以建立一個 AppProviders 元件來整理:

tsx
// providers/AppProviders.tsx — 把所有 Provider 整合在一起
export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider>
      <UserProvider>
        <CartProvider>
          {children}
        </CartProvider>
      </UserProvider>
    </ThemeProvider>
  );
}

// app/layout.tsx — 乾淨很多
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <AppProviders>{children}</AppProviders>
      </body>
    </html>
  );
}

Context 的效能陷阱 — 什麼時候不該用

這是最重要的 section。很多人把 Context 當 Redux 用,把所有狀態都塞進去, 結果導致整個 app 頻繁、不必要地重新渲染。

Context 的重渲染機制

Context 的 value 只要改變,所有呼叫了 useContext 的元件都會重新渲染, 無論它們用到的是不是那個改變的值。

tsx
// ❌ 壞做法:把不相關的狀態混在同一個 Context
const AppContext = createContext({
  count: 0,      // 每秒更新一次的計數器
  user: null,    // 幾乎不變
  theme: 'light' // 極少變動
});

// 每次 count 變化(每秒一次):
// → 所有用到 AppContext 的元件都重新渲染
// → 即使元件只用到 user,根本不在乎 count!

解法是:依據更新頻率,把 Context 拆分成多個獨立的 Context

tsx
// ✅ 好做法:按更新頻率拆分
const CountContext = createContext(0);        // 高頻更新 → 單獨隔離
const UserContext = createContext(null);      // 低頻更新 → 獨立管理
const ThemeContext = createContext('light');  // 極低頻 → 獨立管理

// 現在:
// count 每秒更新 → 只有 useContext(CountContext) 的元件重渲染
// UserProfile 用 UserContext → 完全不受 count 影響
// ThemeToggle 用 ThemeContext → 同樣不受 count 影響

Context 適合 vs. 不適合的場景

適合用 Context不適合用 Context
主題 / 語言切換(極少變動)高頻更新的 UI 狀態(滾動位置、動畫值)
登入用戶資訊(每個 session 一次)表單輸入值(每個鍵盤按鍵都更新)
全域 Modal / Toast 狀態動畫 / 過渡狀態(每幀都在變)
購物車(加入商品頻率低)複雜的非同步操作流程

進階最佳化:如果你的 Context value 是一個物件,可以搭配 useMemo 防止不必要的重渲染——const value = useMemo(() => ({ user, setUser }), [user])。 物件字面量每次 render 都會產生新的 reference,會導致所有 consumer 重渲染, useMemo 確保只有 user 真的改變時才建立新物件。

Context vs Zustand vs Redux

「既然 Context 有效能問題,我應該直接用 Redux 嗎?」 這個問題很常見。答案是:要看 app 的規模和複雜度

⚛️

Context API

內建

優點

  • • 零依賴,React 內建
  • • 學習成本低
  • • 適合簡單的跨元件狀態
  • • TypeScript 整合自然

限制

  • • 效能需自行最佳化
  • • 沒有 DevTools
  • • 複雜狀態邏輯難以維護

適合:中小型 app、主題/用戶/語言等低頻全域狀態

🐻

Zustand

2KB

優點

  • • 極輕量(2KB gzip)
  • • API 非常直觀
  • • 原生支援選擇性訂閱(效能好)
  • • 不需要 Provider 包裝

限制

  • • 需要額外安裝
  • • 生態系比 Redux 小

適合:中大型 app、需要高效能全域狀態管理

🏗️

Redux Toolkit

完整生態

優點

  • • 完整的 DevTools(時間旅行 debug)
  • • 成熟的生態系和社群
  • • 嚴格的單向資料流
  • • 企業級最佳實踐

限制

  • • 學習成本高
  • • 樣板代碼多
  • • 小專案殺雞用牛刀

適合:大型 app、複雜異步邏輯、多人協作大型團隊

2026 年的建議

先用 Context,複雜了再遷移到 Zustand,不要一上來就用 Redux。

  • • 新專案:Context API(零成本,先跑起來)
  • • 遇到效能問題或狀態邏輯複雜:遷移到 Zustand(API 差不多,改起來快)
  • • 企業大型專案或已有 Redux 基礎:Redux Toolkit(有 DevTools 優勢)

從 Context 遷移到 Zustand 成本很低——因為你已經把邏輯封裝在自訂 Hook 裡了, 只要換掉 Hook 的內部實作,元件完全不用改。這也是為什麼「封裝成自訂 Hook」是重要習慣。

本篇重點整理

  • Props Drilling 的痛點:中間元件被迫傳遞不相干的 props,型別改動要多層更新
  • Context 三步驟:createContext → Provider(廣播站)→ useContext(收聽)
  • 把 useContext 封裝成自訂 Hook(如 useUser、useTheme),確保在 Provider 內使用
  • Provider 巢狀:從最不常變的放外層(Theme > User > Cart)
  • 效能陷阱:按更新頻率拆分 Context;考慮 useMemo 防止不必要重渲染
  • 選型建議:Context → Zustand → Redux,按複雜度遞進,不要過度設計
Context API
useContext
Props Drilling
ThemeContext
State Management
React