Blog

/

Frontend

useContext Provider 模式 React 全局狀態管理完全指南

useContext props drilling React Context useContext Context useContext ReduxZustand

Posted at

2024年11月1日

Posted on

Frontend

useContext 和 Provider 模式是處理跨層級狀態的標準做法,過程中不用逐層傳遞 props,直接在組件中讀取全局狀態,但如果用錯了就會造成性能問題和難以預測的重新渲染。

useContext 讓組件有能力存取在上層 Provider 中定義的值,避免了深層嵌套時的 props drilling(層層傳遞 props)。但 Context 改變時,所有訂閱該 Context 的組件都會重新渲染,即使它們只用了其中一部分狀態,針對 useContext 性能優化就是這個議題。Provider 模式就是創建一個 Context,在上層組件用 Provider 包裹,下層組件通過 useContext 讀取。

特性

Props Drilling

useContext

傳遞方式

逐層傳遞

直接注入

性能

按需更新

整體重新渲染

何時用

淺層結構

深層或全局狀態

重新渲染控制

精細

需優化

useContext

useContext 需要配合 createContext 和 Provider 使用:

import { createContext, useContext } from 'react'

const ThemeContext = createContext()

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

export function useTheme() {
  return useContext(ThemeContext)
}

上層組件用 ThemeProvider 包裹,下層組件用 useTheme Hook 讀取狀態,不用去逐層傳遞。

實例 1:使用者身份驗證和權限

在 POS 系統中,很多組件需要知道當前登入的使用者和他們的權限,這時候如果用 props drilling 會很麻煩。

'use client'

import { createContext, useContext, useState, useEffect } from 'react'

const AuthContext = createContext()

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null)
  const [permissions, setPermissions] = useState([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const fetchUser = async () => {
      try {
        const res = await fetch('/api/auth/me')
        if (res.ok) {
          const data = await res.json()
          setUser(data.user)
          setPermissions(data.permissions)
        }
      } catch (error) {
        console.error('Failed to fetch user:', error)
      } finally {
        setLoading(false)
      }
    }

    fetchUser()
  }, [])

  return (
    <AuthContext.Provider value={{ user, permissions, loading }}>
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider')
  }
  return context
}

// 在其他組件中使用
export function OrderList() {
  const { user, permissions } = useAuth()

  const canDeleteOrder = permissions.includes('delete_order')

  return (
    <div>
      <h2>訂單列表</h2>
      <p>當前使用者: {user?.name}</p>
      {canDeleteOrder && <button>刪除訂單</button>}
    </div>
  )
}

任何組件都可以呼叫 useAuth 獲取使用者資訊和權限,組件掛載時就會去自動檢查登入狀態。

實例 2:主題和語言切換

電商平台通常支援深色模式和多語言,這些設定影響全局,需要在很多組件中使用。

'use client'

import { createContext, useContext, useState } from 'react'

const LocalizationContext = createContext()

export function LocalizationProvider({ children }) {
  const [theme, setTheme] = useState('light')
  const [language, setLanguage] = useState('en')

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light')
  }

  const switchLanguage = (lang) => {
    setLanguage(lang)
  }

  return (
    <LocalizationContext.Provider
      value={{
        theme,
        language,
        toggleTheme,
        switchLanguage
      }}
    >
      {children}
    </LocalizationContext.Provider>
  )
}

export function useLocalization() {
  return useContext(LocalizationContext)
}

// 在組件中使用
export function Header() {
  const { theme, language, toggleTheme, switchLanguage } = useLocalization()

  return (
    <header style={{
      backgroundColor: theme === 'dark' ? '#333' : '#fff',
      color: theme === 'dark' ? '#fff' : '#000'
    }}>
      <button onClick={toggleTheme}>
        切換到 {theme === 'light' ? '深色' : '淺色'} 模式
      </button>

      <select value={language} onChange={(e) => switchLanguage(e.target.value)}>
        <option value="en">English</option>
        <option value="zh">中文</option>
        <option value="ja">日本語</option>
      </select>
    </header>
  )
}

export function Footer() {
  const { theme } = useLocalization()

  return (
    <footer style={{
      backgroundColor: theme === 'dark' ? '#222' : '#f0f0f0',
      color: theme === 'dark' ? '#fff' : '#000'
    }}>
      Footer content
    </footer>
  )
}

Header 和 Footer 都能讀取主題和語言設定,改變任何一個設定時,依賴它們的組件自動更新。

實例 3:購物車狀態

在電商平台中,購物車是全局狀態。結帳頁、側邊欄、價格總計都需要讀取購物車。

'use client'

import { createContext, useContext, useReducer } from 'react'

const CartContext = createContext()

const initialCart = {
  items: [],
  totalPrice: 0,
  itemCount: 0
}

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find(item => item.id === action.payload.id)
      if (existing) {
        return {
          ...state,
          items: state.items.map(item =>
            item.id === action.payload.id
              ? { ...item, quantity: item.quantity + 1 }
              : item
          ),
          itemCount: state.itemCount + 1,
          totalPrice: state.totalPrice + action.payload.price
        }
      }
      return {
        items: [...state.items, { ...action.payload, quantity: 1 }],
        itemCount: state.itemCount + 1,
        totalPrice: state.totalPrice + action.payload.price
      }
    }

    case 'REMOVE_ITEM': {
      const item = state.items.find(i => i.id === action.payload)
      return {
        items: state.items.filter(i => i.id !== action.payload),
        itemCount: state.itemCount - (item?.quantity || 0),
        totalPrice: state.totalPrice - (item ? item.price * item.quantity : 0)
      }
    }

    case 'UPDATE_QUANTITY': {
      const item = state.items.find(i => i.id === action.payload.id)
      const priceDiff = (action.payload.quantity - item.quantity) * item.price
      return {
        items: state.items.map(i =>
          i.id === action.payload.id
            ? { ...i, quantity: action.payload.quantity }
            : i
        ),
        itemCount: state.itemCount + (action.payload.quantity - item.quantity),
        totalPrice: state.totalPrice + priceDiff
      }
    }

    case 'CLEAR':
      return initialCart

    default:
      return state
  }
}

export function CartProvider({ children }) {
  const [cart, dispatch] = useReducer(cartReducer, initialCart)

  return (
    <CartContext.Provider value={{ cart, dispatch }}>
      {children}
    </CartContext.Provider>
  )
}

export function useCart() {
  return useContext(CartContext)
}

// 在組件中使用
export function ShoppingCart() {
  const { cart, dispatch } = useCart()

  return (
    <div>
      <h2>購物車({cart.itemCount} 件)</h2>
      <ul>
        {cart.items.map(item => (
          <li key={item.id}>
            {item.name} - ${item.price} x {item.quantity}
            <button onClick={() => dispatch({
              type: 'UPDATE_QUANTITY',
              payload: { id: item.id, quantity: item.quantity - 1 }
            })}>
              減少
            </button>
            <button onClick={() => dispatch({
              type: 'REMOVE_ITEM',
              payload: item.id
            })}>
              刪除
            </button>
          </li>
        ))}
      </ul>
      <p>總金額: ${cart.totalPrice}</p>
    </div>
  )
}

export function Checkout() {
  const { cart } = useCart()

  return (
    <div>
      <h2>結帳</h2>
      <p>您購買了 {cart.itemCount} 件商品</p>
      <p>總金額: ${cart.totalPrice}</p>
    </div>
  )
}

購物車狀態由 CartProvider 管理,ShoppingCart 和 Checkout 都能存取,這樣在修改購物車時,兩個組件都會自動更新。

Context 的性能問題和 useContext 記憶化

Context 最大的問題是重新渲染失去控制,也是 useContext 優化性能的過程中最關鍵的一點。當 Context value 改變時,所有訂閱該 Context 的組件都會重新渲染,即使它們只用了 value 的一部分,這種 React Context 重新渲染問題在大型系統中特別明顯。

// 不好的做法
const value = {
  user,
  theme,
  language,
  permissions,
  cart,
  updateCart,
  setTheme
  // ... 越來越多的東西
}

<AuthContext.Provider value={value}>
  {children}
</AuthContext.Provider>

// 現在任何改變 theme 都會重新渲染所有訂閱 AuthContext 的組件
// 即使某些組件只關心 user 和 permissions

解決方法是拆分 Context,針對不同用途的狀態用不同的 Context,可以精細控制重新渲染的範圍。

// 好的做法
const AuthContext = createContext()
const ThemeContext = createContext()
const CartContext = createContext()

export function AppProvider({ children }) {
  return (
    <AuthProvider>
      <ThemeProvider>
        <CartProvider>
          {children}
        </CartProvider>
      </ThemeProvider>
    </AuthProvider>
  )
}

每個 Context 只管理相關的狀態。改變主題時只會重新渲染 ThemeContext 的訂閱者,不會影響 AuthContext 或 CartContext 的訂閱者。

常見錯誤

誤區 1:把所有狀態放在一個 Context

// 不好
const AppContext = createContext()

function AppProvider({ children }) {
  const [user, setUser] = useState(null)
  const [theme, setTheme] = useState('light')
  const [cart, setCart] = useState([])
  const [notifications, setNotifications] = useState([])
  // ... 還有更多

  return (
    <AppContext.Provider value={{ user, theme, cart, notifications }}>
      {children}
    </AppContext.Provider>
  )
}

任何狀態改變都會觸發整個 App 的重新渲染。這會嚴重影響性能。

誤區 2:Context value 沒有記憶化

// 不好
export function AuthProvider({ children }) {
  const [user, setUser] = useState(null)

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

// 每次 AuthProvider 重新渲染,value 都是新對象
// 所有訂閱 AuthContext 的組件都會重新渲染

這是 React Context 重新渲染的常見原因,正確的 useContext 記憶化做法是用 useMemo:

// 好 - useContext 記憶化最佳實踐
export function AuthProvider({ children }) {
  const [user, setUser] = useState(null)

  const value = useMemo(() => ({ user, setUser }), [user])

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  )
}

誤區 3:在 Context 中做複雜邏輯

// 不好
export function CartProvider({ children }) {
  const [cart, setCart] = useState([])

  const addToCart = (product) => {
    // 複雜的邏輯,可能涉及 API 調用
    fetch('/api/add-to-cart', { body: JSON.stringify(product) })
      .then(res => res.json())
      .then(data => setCart(data))
  }

  return (
    <CartContext.Provider value={{ cart, addToCart }}>
      {children}
    </CartContext.Provider>
  )
}

Context 應該保持簡單,複雜邏輯應該在 useReducer 或自訂 Hook 中。

何時選擇 useContext vs Redux vs Zustand

特性

useContext

Redux

Zustand

性能表現

需優化

優秀

優秀

重新渲染控制

粗糙

精細

精細

DevTools 支援

優秀

包大小

0KB(內建)

2.2KB

1.2KB

非同步操作

需自行處理

Middleware 完善

簡單

時間旅行除錯

適用場景

簡單全局狀態

大型複雜應用

中型應用、快速開發

useContext 記憶化難度

中等

無需考慮

無需考慮

useContext 最適合的場景

  • 應用規模小(< 100 個組件)

  • 狀態改變不頻繁(登入狀態、主題、語言)

  • 不需要復雜的中間件或時間旅行除錯

  • 優先考慮減少依賴和包大小

:個人部落格、中小型電商平台、POS 收銀系統的用戶身份和權限

Redux 最適合的場景

  • 應用規模大(> 500 個組件)

  • 狀態轉換複雜且需要記錄每一步

  • 需要強大的 DevTools 和時間旅行除錯

  • 多人協作開發,需要明確的狀態架構

:金融平台、大型電商、企業級 SaaS 應用

Zustand 最適合的場景

  • 應用規模中等(100-500 個組件)

  • 想要性能優化但不想學習複雜 API

  • 需要簡單的非同步操作支援

  • 青睞函數式而不是 action/reducer 模式

:中型電商、設計工具、實時協作應用

實際建議

在 Next.js 中:

  1. 使用者身份、權限、主題 → useContext(必須拆分 Context)

  2. 購物車、訂單狀態 → 先試 useContext + useReducer,卡頓再考慮 Zustand

  3. 複雜業務邏輯、大量即時數據 → Redux 或 Zustand

  4. 伺服器狀態(API 數據)→ React Query / TanStack Query(不是 useContext)

參考資源