[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
裡的值,因此這時稱它為預設值也可以接受。
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 註解對後續理解非常重要,建議仔細看一下。
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
。
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
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
。
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
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
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>
);
}