跳至主要内容

[React] useContext

跨多組件狀態傳遞​

useContext 是 React 中用來處理跨組件狀態傳遞的一個 hook。熟悉 Vue 的話,它的目的其實就跟 provide/inject 一樣。
什麼叫跨多組件?想像一下有個 state,它放在最上層做管理,但會用到它的是他的孫子的孫子組件,中間好幾代都用不到這個狀態,但為了讓孫子的孫子組件可以用到這個狀態,還是得把 state 透過 props 一代一代傳下去...。
這造成的結果是程式碼會變得不易讀且較難維護。

不使用 useContext 的情況 (React 官方範例)​

App.js

注意現在這裡的 Heading 元件每一個都綁了一個 level prop。
這樣的寫法在巢狀結構越深的情況下會變得難以維護,因為每層都得手動指定正確的 level,不但冗長,也容易出錯。

使用 useContext,由 Section 統一管理每層的 level,然後透過 context 傳遞給底下所有的 Heading 可以讓相同層級的 Heading 自動取得對應的 level,不必每個都手動指定,大幅簡化程式碼,也提升了可維護性與可讀性。

import Heading from './Heading.js';
import Section from './Section.js';

export default function Page() {
return (
<Section>
<Heading level={1}>Title</Heading>
<Section>
<Heading level={2}>Heading</Heading>
<Heading level={2}>Heading</Heading>
<Heading level={2}>Heading</Heading>
<Section>
<Heading level={3}>Sub-heading</Heading>
<Heading level={3}>Sub-heading</Heading>
<Heading level={3}>Sub-heading</Heading>
<Section>
<Heading level={4}>Sub-sub-heading</Heading>
<Heading level={4}>Sub-sub-heading</Heading>
<Heading level={4}>Sub-sub-heading</Heading>
</Section>
</Section>
</Section>
</Section>
);
}
Section.js
export default function Section({ children }) {
return (
<section className="section">
{children}
</section>
);
}
Heading.js
export default function Heading({ level, children }) {
switch (level) {
case 1:
return <h1>{children}</h1>;
case 2:
return <h2>{children}</h2>;
case 3:
return <h3>{children}</h3>;
case 4:
return <h4>{children}</h4>;
case 5:
return <h5>{children}</h5>;
case 6:
return <h6>{children}</h6>;
default:
throw Error('Unknown level: ' + level);
}
}

使用 useContext 的情況​

使用 useContext 會先需要建立一個 Context 檔案。 這裡容易誤會的是,會把 useContext 跟 Vue 的 Pinia store 做聯想。 如果把兩者聯想在一起,會容易以為 createContext 裡的值是一個預設值,而且是可以改變的。這其實是錯的!

createContext 內的值,其實比較像是 fallback value。 當一個 component 外層沒有被對應的 context provider 包住,但卻用了 useContext 呼叫某個 context,因為此時沒有 provider 的 value 可以提供,useContext 就會回傳 createContext 裡定義的那個 fallback 值。

不過有一種情況說它是「預設值」也說得通,那就是最外層的 context component。 因為它本身沒有被其他 provider 包住,所以 useContext 這時會回傳 createContext 裡的值,因此這時稱它為預設值也可以接受。

LevelContext.js
import { createContext } from 'react';

export const LevelContext = createContext(0);

React 官方選擇在 Section.js 使用 context,而不是 Heading.js,是因為一個 Section 裡的多個 Heading 共用同一個 level。 所以乾脆統一在 Section.js 中處理 context 的遞增邏輯會更乾淨。 在 Section.js 中我們引入 useContext 和 LevelContext,並透過 provider value 將每層的 level 傳下去。 下面的 code 註解對後續理解非常重要,建議仔細看一下。

Section.js
import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';

export default function Section({ children }) {
const level = useContext(LevelContext); // 這裡會拿到離自己最近的 LevelContext 的值,如果沒有 provider 包住,就會回到 createContext() 中的 fallback 值 0
return (
<section className="section">
<LevelContext value={level + 1}> {/* 將當前的 level + 1,作為下一層的 provider value 傳遞 */}
{children}
</LevelContext>
</section>
);
}
注意

React 19 之前需要寫成 <LevelContext.Provider>,但 React 19 之後可以簡化為 <LevelContext>。
不過 React 19 還是可以繼續使用 .Provider 寫法,這也是為什麼我們稱這個值為 provider value。

接下來清理一下 App.js 的程式碼。
相比原來的 code,現在 App.js 不需要傳遞 level prop 給每個 Heading。

import Heading from './Heading.js';
import Section from './Section.js';

export default function Page() {
return (
<Section> {/* 第一層 Section,拿到 fallback 值 0,然後 +1 傳下去 */}
<Heading>Title</Heading>
<Section> {/* 第二層 Section,level 拿到上一層 +1,也就是 1,再 +1 傳下去 */}
<Heading>Heading</Heading>
<Heading>Heading</Heading>
<Heading>Heading</Heading>
<Section> {/* 第三層 Section,拿到 2,再 +1 傳下去 */}
<Heading>Sub-heading</Heading>
<Heading>Sub-heading</Heading>
<Heading>Sub-heading</Heading>
<Section> {/* 第四層 Section,拿到 3,再 +1 傳下去 */}
<Heading>Sub-sub-heading</Heading>
<Heading>Sub-sub-heading</Heading>
<Heading>Sub-sub-heading</Heading>
</Section>
</Section>
</Section>
</Section>
);
}

最後在使用 level 的 Heading.js 中也引入 useContext 和 LevelContext。

Heading.js
import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';

export default function Heading({ children }) {
const level = useContext(LevelContext);
switch (level) {
case 0:
throw Error('Heading must be inside a Section!');
case 1:
return <h1>{children}</h1>;
case 2:
return <h2>{children}</h2>;
case 3:
return <h3>{children}</h3>;
case 4:
return <h4>{children}</h4>;
case 5:
return <h5>{children}</h5>;
case 6:
return <h6>{children}</h6>;
default:
throw Error('Unknown level: ' + level);
}
}
資訊

再提醒一次:
createContext(initialValue) 中的 initialValue 並不是所有 component 都會拿到。
只有在 component 外層沒有 Provider 包住時,useContext() 才會回傳這個 fallback 值。
一旦有 Provider,哪怕值是 null,useContext() 拿到的就一定是 provider 提供的那個值。

使用 useReducer 搭配 useContext​

在 React 中,useReducer 可以用來管理複雜的 state,而 useContext 則可以用來跨組件傳遞這些 state。
所以他們合在一起可以打出非常厲害的組合技。
一樣是 React 官方的例子:

TaskContext.js​

TaskContext.js
import { createContext, useContext, useReducer } from 'react';

const TasksContext = createContext(null);

const TasksDispatchContext = createContext(null);

// Context Provider 組件
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(
tasksReducer,
initialTasks
);

return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
{children}
</TasksDispatchContext>
</TasksContext>
);
}

// 供其他組件中存取 TasksContext / TasksDispatchContext
export function useTasks() {
return useContext(TasksContext);
}

export function useTasksDispatch() {
return useContext(TasksDispatchContext);
}

// 這裡是 useReducer 的 reducer method
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [...tasks, {
id: action.id,
text: action.text,
done: false
}];
}
case 'changed': {
return tasks.map(t => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter(t => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}

const initialTasks = [
{ id: 0, text: 'Philosopher’s Path', done: true },
{ id: 1, text: 'Visit the temple', done: false },
{ id: 2, text: 'Drink matcha', done: false }
];

App.js​

App.js 中引用 TasksProvider 來包裹其他 child component,這樣就可以讓 child component 都能存取到 TasksContext 和 TasksDispatchContext。

App.js
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksProvider } from './TasksContext.js';

export default function TaskApp() {
return (
<TasksProvider>
<h1>Day off in Kyoto</h1>
<AddTask />
<TaskList />
</TasksProvider>
);
}

AddTask.js​

AddTask.js
import { useState } from 'react';
import { useTasksDispatch } from './TasksContext.js';

export default function AddTask() {
const [text, setText] = useState('');
const dispatch = useTasksDispatch();
return (
<>
<input
placeholder="Add task"
value={text}
onChange={e => setText(e.target.value)}
/>
<button onClick={() => {
setText('');
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}}>Add</button>
</>
);
}

let nextId = 3;

TaskList.js​

TaskList.js
import { useState } from 'react';
import { useTasks, useTasksDispatch } from './TasksContext.js';

export default function TaskList() {
const tasks = useTasks();
return (
<ul>
{tasks.map(task => (
<li key={task.id}>
<Task task={task} />
</li>
))}
</ul>
);
}

function Task({ task }) {
const [isEditing, setIsEditing] = useState(false);
const dispatch = useTasksDispatch();
let taskContent;
if (isEditing) {
taskContent = (
<>
<input
value={task.text}
onChange={e => {
dispatch({
type: 'changed',
task: {
...task,
text: e.target.value
}
});
}} />
<button onClick={() => setIsEditing(false)}>
Save
</button>
</>
);
} else {
taskContent = (
<>
{task.text}
<button onClick={() => setIsEditing(true)}>
Edit
</button>
</>
);
}
return (
<label>
<input
type="checkbox"
checked={task.done}
onChange={e => {
dispatch({
type: 'changed',
task: {
...task,
done: e.target.checked
}
});
}}
/>
{taskContent}
<button onClick={() => {
dispatch({
type: 'deleted',
id: task.id
});
}}>
Delete
</button>
</label>
);
}