跳至主要内容

React Context, Provider and useContext

參考資料
範例程式碼

TL;DR

  • 正確使用 context 的情況下,當 context 中的 value 時,只有使用了 context 的 component (即,Consumer,也就是在該 Component 中有用 useContext(XXXContext) 的元件),會重新 render;要留意的是:「其他在該 Context Provider 底下的元件並不會因此 re-render。」

✅ useContext Best Practice

範例程式碼

請參考 code sandboxCounterBestPractice 資料夾內的寫法。

這裡有一個重點是,資料(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;
}
component composition

如果不清楚為什麼把 children 當成 props 傳入後可以避免不必要的 re-render,可以參考這篇—「The mystery of React Element, children, parents and re-renders」。

使用 Provider:

CounterBestPractice.tsx
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 內的資料和方法:

Counter.tsx
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 並不會:

use-context-best-practice

❌ useContext Anti Pattern

範例程式碼

請參考 code sandboxCounterAntiPattern 資料夾內的寫法。

這裡來寫一個示範,如果我們把資料保存在使用 Provider 的元件,在透過 value 這個 props 傳進來,像是這樣:

CounterProvider.tsx
// ❌ 這是不建議的寫法
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 傳進來:

CounterAntiPattern.tsx
// ❌ 這是不建議的寫法

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:

use-context-anti-pattern

當這麼寫也不是完全沒救,在 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:

use-context-anti-pattern-memo

為什麼 Wrapper 包 memo 也沒用

由於 Wrapper 元件會接受 children 當作它的 props,對於這種元件來說,因為 children 每次都是新的,所以即使包了 memo 也沒有。簡單來說,對於會接受 children 作為 props 的元件來說,不需要包 memo,因為包了也沒用。

useContext + useReducer

範例程式碼

在 Context 中也可以搭配 useReducer 來使用,例如:

CounterProvider.tsx
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;
}

接著可以直接在需要的元件取出資料和方法:

Counter.tsx
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,例如 itemssetItems,但如果某些元件只需要使用到 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);