學會管理 React 狀態:React Context Pattern
Prop Drilling 問題
當你需要將 state 傳遞到位於多層底下的子 component 時,state 變成 props 經過很多不相干的 components,在這個過程中,開發者可能出於某種原因更改 props 的命名。然後過了某個時間點,你需要修改 component 中的某些邏輯,結果就是打開三四五個檔案,慢慢沿着 props chain 找回 state 的來源,在 React 生態系中,我們將這個問題稱之爲 prop drilling。
很多時候會出現 prop drilling 都是因爲 application 中有很多 component 都需要引用同一個 state(Global state),例如登入資料,色彩主題等,所以我們才把 state 提升到最上層來分享給子 component 使用。
來源:The React Cookbook: Advanced Recipes to Level Up Your Next App
以狀態管理器解決 Prop Drilling
爲了解決 prop drilling 的問題,很多 React 開發者都會引入狀態管理器(State manager)來管理這些 Global state;其中一個很有名的狀態管理器便是 Redux,Redux 雖然不是 React 獨佔的狀態管理器,但仍廣泛在 React 生態圈中被使用(react-redux 套件)。
不過要說 Redux 的缺點,就是它有一定的學習成本,開發者需要理解 Dispatcher、Action、Reducer 等等 Redux 資料流的抽象概念,在面對 Asynchronous Action 的時候甚至要引入 Middleware...,實現整個流程的 boilerplate 更是巨多。
來源:https://chentsulin.github.io/redux/docs/basics/UsageWithReact.html
但這篇的主角不是 Redux,詳盡的用法可以參考筆者前陣子的介紹:
Component composition
那麼有沒有其他解決 prop drilling 的手段呢?有!用另一個狀態管理器!在現階段的 React 生態系中,除了 Redux 還有很多優秀的狀態管理器,其中 Zustand 因其寫法簡單更是受到不少開發者青睞。但是,其實很多時候我們根本不需要引用第三方的套件來解決 prop drilling 問題。React 官方文件鼓勵開發者多利用 component composition 的概念:
function FirstComponent({ children }) {
return (
<div>
<h3>I am the first component</h3>;
{ children }
</div>
);
}
function SecondComponent({ children }) {
return (
<div>
<h3>I am the second component</h3>;
{children}
</div>
);
}
function ThirdComponent({ children }) {
return (
<div>
<h3>I am the third component</h3>
{children}
</div>
);
}
function ComponentNeedingProps({ content }) {
return <h3>{content}</h3>
}
export default function App() {
const content = "Who needs me?";
return (
<div className="App">
<FirstComponent>
<SecondComponent>
<ThirdComponent>
<ComponentNeedingProps content={content} />
</ThirdComponent>
</SecondComponent>
</FirstComponent>
</div>
);
}
Component composition 的確能解決一定程度的 prop drilling 問題,但如果你需要讓不同層級的許多 components 訪問同一組 state,這個方法就沒有什麼作爲了。
React Context API 前來救援
以電商網頁爲例,購物車的 state 需要分享到多個 component 當中,在導覽列上顯示購物車中的品項總數、加入購物車的功能以及顯示購物車中的品項列表都需要共享同一個 state,在這個情形下難以實踐 Component composition,下一步就是嘗試 React 的 Context API 了,其實 Context API 一直都是其中一個對付 prop drilling 的方法,只是因爲長久而來官方不建議使用(當時版本 Context API 尚未成熟)。但現在 Context 已經是官方支持的 API 了,所以我們應該放心使用:
// context/CartContext.tsx
import { CartItemType } from 'models/interfaces';
import * as React from 'react'
// 使用 TypeScript 作爲例子,建立 Context 時需要提供 Default value!
const cartContextDefaultValue = {
cartItems: [] as CartItemType[],
setCartItems: (_: CartItemType[]) => {},
};
// 建立 Context
const CartContext = React.createContext(cartContextDefaultValue);
Custom Provider 將資料初始化的邏輯抽離
// context/CartContext.tsx
// 建立 Provider(使用 context's state 的 components 需要被包在 Provider 當中)
export function CartProvider(props: any): JSX.Element {
// useState 的內容會傳遞到 context 當中
const [cartItems, setCartItems] = React.useState(cartContextDefaultValue.cartItems);
// 謹記配合 useMemo 使用,只有在 cartItems 的值更動才觸發使用 context 的 component 重新渲染
const value = useMemo(() => ({ cartItems, setCartItems }), [cartItems]);
// 業務邏輯也可以在 Provider 中實現
useEffect(() => {
const cartItemsData = JSON.parse(localStorage.getItem('cart') || '[]');
if (cartItemsData) {
setCartItems(cartItemsData);
}
}, []);
return (
<CartContext.Provider value={value}>{props.children}</CartContext.Provider>
);
}
// pages/_app.tsx
import type { AppProps } from 'next/app';
import Header from '@/components/Header';
import { CartProvider } from 'context/CartContext';
// 將需要用到 context's state 的 component 包在 Provider 內
const MyApp = ({ Component, pageProps }: AppProps) => {
return (
<CartProvider>
<Header />
<Component {...pageProps} />
</CartProvider>
);
};
export default MyApp;
之後子 component 便可以呼叫 useContext({Context}) 獲取 context’s state 了:
// components/Header.tsx
import * as React from 'react';
import { CartContext } from '../context/CartContext'
export default function Header() {
const { cartItems, setCartItems } = React.useContext(CartContext)
return (
{/* ... */}
<span
className={`has-badge ${
getTotalAmount(cartItems) > 0 && 'active'
}`}
data-count={getTotalAmount(cartItems)}
>
<IoCartSharp size={20} />
</span>
{/* ... */}
)
}
Custom Consumer Hook 抽象使用 Context 的程式碼
看似完美,但是實則還是有個小問題,Context 不一定每次都處於 Top level,Context 只在一部分中可用實際上是非常常見的,若是在 Header 實際上不在 CartProvider 內,這時候 useContext(CartContext) 回傳的 Object 便是 undefined;其中一個比較簡單的做法是將 useContext 邏輯抽到另一個 custom hook 上:
// context/CartContext.tsx
export function useCart() {
const context = useContext(CartContext);
// undefined 的時候直接報錯
if (context === undefined) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}
// components/Header.tsx
import { useCart } from '../context/CartContext'
export default function Header() {
const { cartItems, setCartItems } = useCart()
// ...
}
我們現在只需要導入 custom consumer hook,不需要知道要導入哪一個 context 了😆。
使用多個 Context
在實際的應用中,很多時候不只會用到單一個 context;就如本文提到的登入資料,色彩主題等等 UI state 一樣可以放置到各自的 Context 當中,State 更動時 React 只會重新渲染有引用相關context 的 component,可以不用太擔心效能問題:
import type { AppProps } from 'next/app';
import { Provider as AuthProvider } from 'next-auth/client';
import { FilterProvider } from 'context/FilterContext';
import GlobalStyles from 'styles/GlobalStyles';
import Header from '@/components/Header';
import { CartProvider } from 'context/CartContext';
const MyApp = ({ Component, pageProps }: AppProps) => {
return (
<AuthProvider session={pageProps.session}>
<GlobalStyles />
<CartProvider>
<FilterProvider>
<Header />
<Component {...pageProps} />
</FilterProvider>
</CartProvider>
</AuthProvider>
);
};
export default MyApp;
React Context 不是萬靈丹
React Context 雖然很好用,但並不是每一個情形都需要用到它,在絕大部分的時候 useState 就足以解決需求,濫用 useContext 還是有可能會導致效能問題。請謹記,解決 prop drilling 不只有 Context 這個方法,簡單的 prop drilling 問題還是推薦透過 component composition 技巧解決。
參考資料
How to use React Context like a pro (https://devtrium.com/posts/how-use-react-context-pro)
Application State Management with React (https://kentcdodds.com/blog/application-state-management-with-react)