useContext
在 React 中,如果元件的層級結構較深,如果要將資料從最上層的元件傳遞到最底層的元件,不得不一路將資料透過 props 傳遞下去,即使中間的元件並不需要這些資料都得幫忙代轉,這就是所謂的「prop drilling」問題。
比較直覺的例子是:
tsx
function App() {
return <A user="Marsgoat" />;
}
function A(props) {
return <B user={props.user} />;
}
function B(props) {
return <C user={props.user} />;
}
function C(props) {
return <D user={props.user} />;
}
function D(props) {
return <E user={props.user} />;
}
function E({ user }) {
return <div>Hello {user}</div>;
}只有 E 元件需要 user 資料,但 A → B → C → D 都得幫忙傳。
而解決這個問題的方法之一,就是使用 React 內建的 Context API,透過 Context 可以讓我們更方便地在元件樹中傳遞資料。
語法
主要步驟如下:
- 使用
createContext建立一個全域的 Context 物件。 - 在元件樹的上層使用
<Context.Provider value={...}>(React 18) 或Context value={...}(React 19) 提供資料。 - 在需要使用資料的元件中,使用
useContext取得 Context。
tsx
import { createContext, useContext } from 'react';
const ThemeContext = createContext({ theme: 'light' });
function App() {
return (
<>
<ThemeContext.Provider value={{ theme: 'light' }}>
<Child />
</ThemeContext.Provider>
</>
);
}
function Child() {
return (
<>
<h2>Child</h2>
<GrandChild />
</>
);
}
function GrandChild() {
const { theme } = useContext(ThemeContext);
return <p>GrandChild - Current theme: {theme}</p>;
}基本使用
來寫一個傳遞主題 (theme) 的範例,要注意在 React 19 中,Provider 的寫法有些許不同。
React 18 範例
首先我們先透過 createContext 建立一個 ThemeContext,然後透過這個 ThemeContext 建立的元件包裹整個應用程式,並提供主題資料。
只要是被包裹的元件,無論在哪個層級,都可以使用 useContext 來取得主題資料。
tsx
import type { ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
type Theme = 'light' | 'dark';
type ThemeContextValue = {
theme: Theme;
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme must be used within ThemeProvider');
}
return ctx;
}
export { ThemeProvider, useTheme };tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ThemeProvider } from './context/ThemeContext';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>
);tsx
import type { CSSProperties } from 'react';
import { useTheme } from './context/ThemeContext';
function ThemeToggleButton() {
const { theme, toggleTheme } = useTheme();
const label = theme === 'light' ? '切換為深色' : '切換為淺色';
return (
<button type="button" onClick={toggleTheme}>
{label}
</button>
);
}
function ThemedBox({ title }: { title: string }) {
const { theme } = useTheme();
const styles: CSSProperties = {
backgroundColor: theme === 'light' ? '#ffffff' : '#111111',
border: '1px solid #e5e5e5',
width: '120px',
height: '120px',
color: theme === 'light' ? '#222222' : '#f5f5f5',
display: 'grid',
placeItems: 'center',
borderRadius: '10px',
transition: 'background-color 150ms ease, color 150ms ease',
};
return <div style={styles}>{title}</div>;
}
function App() {
return (
<div
style={{
display: 'grid',
placeItems: 'center',
gap: '16px',
}}
>
<ThemeToggleButton />
<div style={{ display: 'flex', gap: '12px' }}>
<ThemedBox title="Parent" />
<ThemedBox title="Child" />
</div>
</div>
);
}React 19 範例
TIP
其實 19 版和 18 版是差不多的,只是不用再使用 <ThemeContext.Provider>,而是直接使用 <ThemeContext> 元件來包裹即可,不過仍然向下相容 18 版的寫法。
tsx
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<ThemeContext value={{ theme, toggleTheme }}>
{children}
<ThemeContext.Provider>
</ThemeContext>
);
}結合購物車範例
現在我們將之前所用 reducer 編寫的購物車範例,用 Context 來管理各個元件之間的購物車狀態。
tsx
import { createContext, useContext, type ReactNode } from 'react';
import { useImmerReducer } from 'use-immer';
import { cartReducer, initialState } from './cartReducer';
import type { CartState, Product } from './types';
type CartContextType = {
state: CartState;
addToCart: (product: Product) => void;
updateQuantity: (id: number, quantity: number) => void;
removeItem: (id: number) => void;
clearCart: () => void;
};
const CartContext = createContext<CartContextType | undefined>(undefined);
function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useImmerReducer(cartReducer, initialState);
const addToCart = (product: Product) => {
dispatch({ type: 'ADD_ITEM', payload: product });
};
const updateQuantity = (id: number, quantity: number) => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } });
};
const removeItem = (id: number) => {
dispatch({ type: 'REMOVE_ITEM', payload: { id } });
};
const clearCart = () => {
dispatch({ type: 'CLEAR_CART' });
};
return (
<CartContext
value={{
state,
addToCart,
updateQuantity,
removeItem,
clearCart,
}}
>
{children}
</CartContext>
);
}
function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}
export { CartProvider, useCart };tsx
import ShoppingCart from './components/ShoppingCart';
import { CartProvider } from './components/ShoppingCart/CartContext';
function App() {
return (
<CartProvider>
<ShoppingCart />
</CartProvider>
);
}tsx
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
const handleAddToCart = (product: Product) => {
dispatch({ type: 'ADD_ITEM', payload: product });
};
const handleUpdateQuantity = (id: number, quantity: number) => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } });
};
const handleRemove = (id: number) => {
dispatch({ type: 'REMOVE_ITEM', payload: { id } });
};
const handleClear = () => {
dispatch({ type: 'CLEAR_CART' });
};
return (
<div className={styles.container}>
<h1 className={styles.title}>🛒 購物車範例</h1>
{/* 商品列表 */}
<section>
<h2>商品列表</h2>
<div className={styles.productList}>
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onAddToCart={handleAddToCart}
/>
))}
</div>
</section>
{/* 購物車內容 */}
<section className={styles.cartSection}>
<CartTable
items={state.items}
totalAmount={state.totalAmount}
onUpdateQuantity={handleUpdateQuantity}
onRemove={handleRemove}
onClear={handleClear}
/>
<CartTable />
</section>
</div>
);
}將原本在 ShoppingCart 元件中使用的 useReducer 管理的狀態與操作函式,移到了 CartContext 中,然後在外層使用 CartProvider 包裹整個購物車元件,這樣 ShoppingCart 元件以及其子元件就可以透過 useCart 來取得購物車的狀態與操作函式,而不需要再透過 props 傳遞。
tsx
function ProductCard({ product, onAddToCart }: ProductCardProps) {
function ProductCard({ product }: ProductCardProps) {
const { addToCart } = useCart();
return (
<div className={styles.productCard}>
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => addToCart(product)}>
加入購物車
</button>
</div>
);
}tsx
function CartTable({ items, totalAmount, onUpdateQuantity, onRemove, onClear }: CartTableProps) {
function CartTable() {
const { state, clearCart } = useCart();
const { items, totalAmount } = state;
if (items.length === 0) {
return <p className={styles.emptyCart}>購物車是空的</p>;
}
return (
<>
<h2>購物車 ({items.length} 項商品)</h2>
<table className={styles.table}>
<thead>
<tr className={styles.tableHeader}>
<th className={styles.textLeft}>商品</th>
<th className={styles.textCenter}>單價</th>
<th className={styles.textCenter}>數量</th>
<th className={styles.textCenter}>小計</th>
<th className={styles.textCenter}>操作</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<CartItemRow
key={item.id}
item={item}
onUpdateQuantity={onUpdateQuantity}
onRemove={onRemove}
/>
))}
</tbody>
</table>
<div className={styles.cartFooter}>
<h3 className={styles.totalAmount}>總金額: ${totalAmount}</h3>
<button onClick={clearCart} className={styles.clearButton}>
清空購物車
</button>
</div>
</>
);
}tsx
function CartItemRow({ item, onUpdateQuantity, onRemove }: CartItemRowProps) {
function CartItemRow({ item }: CartItemRowProps) {
const { updateQuantity, removeItem } = useCart();
return (
<tr className={styles.tableRow}>
<td>{item.name}</td>
<td className={styles.textCenter}>${item.price}</td>
<td className={styles.textCenter}>
<div className={styles.quantityControl}>
<button onClick={() => updateQuantity(item.id, item.quantity - 1)}>-</button>
<span>{item.quantity}</span>
<button onClick={() => updateQuantity(item.id, item.quantity + 1)}>+</button>
</div>
</td>
<td className={styles.textCenter}>${item.price * item.quantity}</td>
<td className={styles.textCenter}>
<button onClick={() => removeItem(item.id)} className={styles.deleteButton}>
刪除
</button>
</td>
</tr>
);
}