React Context, Provider and useContext
- 👍 How to use React context effectively @ KCD
- Context @ React
- useContext @ React Hooks Reference
- Passing Data Deeply with Context @ React Docs beta
- code sandbox 中
CounterBestPractice
資料夾內的寫法。 - count-context @ pjchender gist
- User Context @ pjchender GitHub
- react-context-render @ CodeSandbox
TL;DR
- 正確使用 context 的情況下,當 context 中的 value 時,只有使用了 context 的 component (即,Consumer,也就是在該 Component 中有用
useContext(XXXContext)
的元件),會重新 render;要留意的是:「其他在該 Context Provider 底下的元件並不會因 此 re-render。」
✅ useContext Best Practice
請參考 code sandbox 中 CounterBestPractice
資料夾內的寫法。
這裡有一個重點是,資料(state)是定義在 Provider,而不是定義在使用 Provider 的元件:
- 當把資料(state)定義在 Provider 內,當這個資料改變時,只有 Consumer 會重新 render
- 但如果是把資料(state)定義在「使用 Provider 的元件」,才用 value 的方式把資料傳給 Provider,當這個資料改變時,所有 Provider 底下的元件也都會重新 render
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
/**
* 1. 建立 Context
*/
// 定義 Context 中 value 的型別
interface ICounterContextData {
count: number;
addCount: () => void;
}
const CounterContext = createContext<ICounterContextData | undefined>(undefined);
/**
* 2. 建立 Provider 元件
*/
// 定義 Provider 元件 Props 的型別
interface ICounterProviderProps {
defaultCount?: number;
children: React.ReactNode;
}
export function CounterProvider({ defaultCount = 0, children }: ICounterProviderProps) {
// 將資料放在 Provider,而不是放在使用 Provider 的元件才透過 "value" 傳進來
// 其他的部分把 children 當成 props 傳進來(一種 composition 的優化作法)
// 如此可以確保在資料改變時,只有使用到此資料的 Consumer 元件會 re-render
const [count, setCount] = useState(defaultCount);
const addCount = useCallback(() => {
setCount((prevCount) => prevCount + 1);
}, []);
// 如果沒有把它 memo 起來,一旦 CounterProvider re-render,其所有的
// consumer 都會 re-render(即使 counter 沒有改變)
const counterContextData: ICounterContextData = useMemo(() => {
return {
addCount,
count,
};
}, [addCount, count]);
return (
<CounterContext.Provider value={counterContextData}>
{/* 這裡用到 component composition 的優化技巧 */}
{children}
</CounterContext.Provider>
);
}
/**
* 3. 建立使用 Context 資料的 hook
*/
export function useCounter() {
const counterContextData = useContext(CounterContext);
// 確保 counterContext 不會是空的
if (counterContextData === undefined) {
throw new Error('useCounter must be used within a CounterProvider');
}
return counterContextData;
}
如果不清楚為什麼把 children 當成 props 傳入後可以避免不必要的 re-render,可以參考這篇—「The mystery of React Element, children, parents and re-renders」。
使用 Provider:
export default function CounterBestPractice() {
return (
<div className="container">
<CounterProvider>
<Wrapper>
<Counter name="first" />
</Wrapper>
<Wrapper>
<Counter name="second" />
</Wrapper>
<Wrapper>
<Counter name="third" />
</Wrapper>
</CounterProvider>
</div>
);
}
在 Counter 中使用 context 內的資料和方法:
import { useCounter } from './CounterProvider';
const Counter = ({ name }: CounterProps) => {
const { count, addCount } = useCounter();
return (
<div>
<p>name: {name}</p>
<p>count: {count} </p>
<button type="button" onClick={addCount}>
+
</button>
</div>
);
};
export default Counter;
可以看到,雖然 <Wrapper>
是放在 <Provider>
和 <Counter>
中間,但是當 Provider 的內的資料改變時,只有 Counter 元件會 re-render,Wrapper 並不會:
❌ useContext Anti Pattern
請參考 code sandbox 中 CounterAntiPattern
資料夾內的寫法。
這裡來寫一個示範,如果我們把資料保存在使用 Provider 的元件,在透過 value
這個 props 傳進來,像是這樣:
// ❌ 這是不建議的寫法
interface ICounterProviderProps {
value: ICounterContextData;
children: React.ReactNode;
}
// 把資料定義在使用 Provider 的元件中,再透過 value 這個 props 傳進來
export function CounterProvider({ value, children }: ICounterProviderProps) {
return <CounterContext.Provider value={value}>{children}</CounterContext.Provider>;
}
把資料定義在使用 Provider 的元件中,再透過 value 這個 props 傳進來:
// ❌ 這是不建議的寫法
export default function CounterAntiPattern() {
// 資料定義在這裡
const [count, setCount] = useState(0);
const addCount = () => {
setCount((prevCount) => prevCount + 1);
};
const data = useMemo(() => {
return {
count,
addCount,
};
}, [count]);
return (
<div className="container">
{/* 透過 props 傳進 Provider */}
<CounterProvider value={data}>
<Wrapper>
<Counter name="first" />
</Wrapper>
<Wrapper>
<Counter name="second" />
</Wrapper>
<Wrapper>
<Counter name="third" />
</Wrapper>
</CounterProvider>
</div>
);
}
如果是這種寫法,因為資料是在元件上,按照 React 原本的邏輯,一旦 state 改變,底下所有的元件都會重新 render 🔥。可以看到,即時只有 count
改變,當 Wrapper
也都會跟著重新 render:
當這麼寫也不是完全沒救,在 React 中還有提供了一個 memo
可以使用,可以用它來把元件包起來,如果該元件的 Props 都沒有變的話,該元件就不會重新 render。
例如在 Wrapper
這個元件中,還有另一個叫做 OtherComponent
的元件,我們可以用 memo
把這個元件包起來:
import { memo, useRef } from 'react';
const OtherComponent = () => {
const renderCount = useRef(0);
renderCount.current += 1;
return <p style={{ border: '1px solid blue' }}>other({renderCount.current})</p>;
};
const MemoOtherComponent = memo(OtherComponent);
如此,如果 OtherComponent
內的 props 沒有改變時,這個元件就不會重新 render:
由於 Wrapper
元件會接受 children
當作它的 props,對於這種元件來說,因為 children 每次都是新的,所以即使包了 memo
也沒有。簡單來說,對於會接受 children
作為 props 的元件來說,不需要包 memo
,因為包了也沒用。
useContext + useReducer
- 請參考 code sandbox 中
CounterWithReducer
資料夾內的寫法。 - count-context @ pjchender gist
- User Context @ pjchender GitHub
- Scaling Up with Reducer and Context @ React Docs beta
- How to use React context effectively @ KCD
在 Context 中也可以搭配 useReducer
來使用,例如:
import React, { createContext, useReducer, useContext } from "react";
type Count = number;
/**
* 定義 Reducer Action
*/
type PlusAction = {
type: "plus";
};
type MinusAction = {
type: "minus";
};
type ResetAction = {
type: "reset";
initialValue: Count;
};
type CounterReducerAction = PlusAction | MinusAction | ResetAction;
/**
* 定義 Reducer
*/
function counterReducer(state: Count, action: CounterReducerAction) {
switch (action.type) {
case "plus": {
return state + 1;
}
case "minus": {
return state - 1;
}
case "reset": {
return action.initialValue;
}
default: {
// @ts-expect-error: action.type should be never
throw new Error(`Unknown action type: ${action.type}`);
}
}
}
/**
* 建立 Context
*/
// 定義 Context 中 value 的型別
interface ICounterContextData {
count: Count;
dispatch: React.Dispatch<CounterReducerAction>;
}
const CounterContext = createContext<ICounterContextData | undefined>(
undefined
);
/**
* 建立 Provider 元件
*/
// 定義 Provider 元件 Props 的型別
interface ICounterProviderProps {
defaultCount?: Count;
children: React.ReactNode;
}
export function CounterProvider({
defaultCount = 0,
children
}: ICounterProviderProps) {
const [count, dispatch] = useReducer(counterReducer, defaultCount);
return (
<CounterContext.Provider
{/* 把 state 和 dispatch 傳進 provider */}
value={{
count,
dispatch
}}
>
{children}
</CounterContext.Provider>
);
}
/**
* 建立使用 Context 資料的 hook
*/
export function useCounter() {
const counterContextData = useContext(CounterContext);
// 確保 counterContext 不會是空的
if (counterContextData === undefined) {
throw new Error("useCounter must be used within a CounterProvider");
}
return counterContextData;
}
接著可以直接在需要的元件取出資料和方法:
import { useCounter } from './CounterProvider';
const Counter = () => {
// 從 context 中取出 useReducer 的 state 和 dispatch
const { count, dispatch } = useCounter();
const addCount = () => {
dispatch({
type: 'plus',
});
};
const minusCount = () => {
dispatch({
type: 'minus',
});
};
const resetCount = () => {
dispatch({
type: 'reset',
initialValue: 0,
});
};
return (
<div>
<p>count: {count} </p>
<button type="button" onClick={minusCount}>
-
</button>
<button type="button" onClick={resetCount}>
reset
</button>
<button type="button" onClick={addCount}>
+
</button>
</div>
);
};
export default Counter;
把 setter 和 getter 分在不同的 Context, 避免不必要的 re-render
當 Context 中的 value 同時包含 getter 和 setter,例如 items
和 setItems
,但如果某些元件只需要使用到 setItems
而不需要使用 items
,這些元件即使只用了 setItems
仍然會經常有不必要的 re-render,這是因為 items
每次都會更新(即使用 useMemo
也沒有,因為 items
會被放在 dependencies array 中)。
這時候有一個可以使用的技巧,是把 setter 和 getter 分成兩個不同的 context。舉例來說,原本的 context 長這樣:
// https://frontendmasters.com/courses/react-performance/usereducer-dispatch/
import { createContext, useState } from 'react';
export const ItemsContext = createContext({});
const ItemsProvider = ({ children }) => {
const [items, setItems] = useState(() => getInitialItems());
return (
// 當 value 這要寫時,即使只有用到 setItems 的 Context Consumer,也會因為 items 改變而 re-render
<ItemsContext.Provider value={{ items, setItems }}>{children}</ItemsContext.Provider>
);
};
export default ItemsProvider;
這時候我們可以考慮改成這樣:
- 新建立一個專門放 setter 的 context (
ActionsContext
)
import { getInitialItems } from './lib/items';
export const ItemsContext = createContext({});
+export const ActionsContext = createContext({});
const ItemsProvider = ({ children }) => {
const [items, setItems] = useState(() => getInitialItems());
+
return (
- <ItemsContext.Provider value={{ items, setItems }}>
- {children}
- </ItemsContext.Provider>
+ <ActionsContext.Provider value={setItems}>
+ <ItemsContext.Provider value={{ items }}>
+ {children}
+ </ItemsContext.Provider>
+ </ActionsContext.Provider>
);
};
對於只需要使用 setItems
的元件,就可以只拿 setter:
// https://frontendmasters.com/courses/react-performance/usereducer-dispatch/
const Item = ({ item }) => {
// 只拿 setter 出來,即使 items 改變了,這個元件也不會 re-render
const setItems = useContext(ActionsContext);
return (
<input
type="checkbox"
className="focus:bg-red-500"
checked={item.packed}
id={`toggle-${item.id}`}
onChange={() =>
// 需要的話,在 setItems 中依然可以拿到 items
setItems((items) => updateItem(items, item.id, { packed: !item.packed }))
}
/>
);
};
搭配 TypeScript 使用
keywords: createContext<ContextType>()
只要一開始 createContext
的型別有定好的話,TypeScript 就會自動推導 Context 的型別:
// theme-context.tsx
import { createContext, ReactNode } from 'react';
type Themes = {
[key: string]: React.CSSProperties;
};
const defaultTheme: Themes = {
light: {
backgroundColor: 'white',
color: 'black',
},
dark: {
backgroundColor: '#555',
color: 'white',
},
};
export const ThemeContext = createContext(defaultTheme);
export const ThemeProvider = ({ children }: { children: ReactNode }) => (
<ThemeContext.Provider value={defaultTheme}>{children}</ThemeContext.Provider>
);
但如果 Context 的初始值和實際的型別不同時,可以透過泛型的方式來明確告知 TypeScript 這個 context 的型別:
// 由於 context 預設的 state 是 null,所以需要透過泛型明確告知 TS 這個 context 的型別
export const ThemeContext = createContext<Themes>(null);