Масштабирование с помощью reducer и context
Reducers позволяют объединить логику обновления state компонента. Context позволяет передавать информацию глубоко вниз другим компонентам. Вы можете комбинировать reducers и context, чтобы управлять state сложного экрана.
Вы узнаете
- Как объединять reducer с context
- Как избежать передачи state и dispatch через props
- Как хранить context и логику state в отдельном файле
Объединение reducer с context
В этом примере из введения в reducers state управляется reducer'ом. Функция reducer содержит всю логику обновления state и объявлена в нижней части этого файла:
import { useReducer } from 'react';import AddTask from './AddTask.js';import TaskList from './TaskList.js';export default function TaskApp() { const [tasks, dispatch] = useReducer( tasksReducer, initialTasks ); function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); } function handleChangeTask(task) { dispatch({ type: 'changed', task: task }); } function handleDeleteTask(taskId) { dispatch({ type: 'deleted', id: taskId }); } return ( <> <h1>Day off in Kyoto</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> );}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); } }}let nextId = 3;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 }];Reducer помогает сделать обработчики событий короткими и понятными. Однако по мере роста приложения может появиться ещё одна сложность. Сейчас state tasks и функция dispatch доступны только в верхнеуровневом компоненте TaskApp. Чтобы другие компоненты могли читать список задач или изменять его, вам нужно явно передавать вниз текущее состояние и обработчики событий, которые его изменяют, через props.
Например, TaskApp передаёт список задач и обработчики событий в TaskList:
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>А TaskList передаёт обработчики событий в Task:
<Task
task={task}
onChange={onChangeTask}
onDelete={onDeleteTask}
/>В небольшом примере это работает хорошо, но если между ними десятки или сотни компонентов, передача всех state и функций вниз может быть очень утомительной!
Именно поэтому, вместо передачи через props, может быть удобнее поместить и state tasks, и функцию dispatch в context. Так любой компонент ниже TaskApp в дереве сможет читать задачи и вызывать actions без повторяющегося "prop drilling".
Вот как можно объединить reducer с context:
- Создайте context.
- Поместите state и dispatch в context.
- Используйте context в любой точке дерева.
Шаг 1: Создайте context
Hook useReducer возвращает текущие tasks и функцию dispatch, которая позволяет их обновлять:
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);Чтобы передать их вниз по дереву, вы создадите два отдельных context:
TasksContextпредоставляет текущий список задач.TasksDispatchContextпредоставляет функцию, которая позволяет компонентам вызывать actions.
Экспортируйте их из отдельного файла, чтобы потом можно было импортировать их в других файлах:
//App.js
import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
export default function TaskApp() {
const [tasks, dispatch] = useReducer(
tasksReducer,
initialTasks
);
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId
});
}
return (
<>
<h1>Day off in Kyoto</h1>
<AddTask
onAddTask={handleAddTask}
/>
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}
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);
}
}
}
let nextId = 3;
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 }
];Здесь в оба context в качестве значения по умолчанию передаётся null. Реальные значения будут предоставлены компонентом TaskApp.
Шаг 2: Поместите state и dispatch в context
Теперь вы можете импортировать оба context в компонент TaskApp. Возьмите tasks и dispatch, возвращаемые useReducer(), и предоставьте их всему дереву ниже:
import { TasksContext, TasksDispatchContext } from './TasksContext.js';
export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
// ...
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
...
</TasksDispatchContext>
</TasksContext>
);
}Пока что вы передаёте информацию и через props, и через context:
//App.js
import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksContext, TasksDispatchContext } from './TasksContext.js';
export default function TaskApp() {
const [tasks, dispatch] = useReducer(
tasksReducer,
initialTasks
);
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId
});
}
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
<h1>Day off in Kyoto</h1>
<AddTask
onAddTask={handleAddTask}
/>
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</TasksDispatchContext>
</TasksContext>
);
}
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);
}
}
}
let nextId = 3;
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 }
];На следующем шаге вы уберёте передачу через props.
Шаг 3: Используйте context в любой точке дерева
Теперь вам не нужно передавать список задач или обработчики событий вниз по дереву:
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
<h1>Day off in Kyoto</h1>
<AddTask />
<TaskList />
</TasksDispatchContext>
</TasksContext>Вместо этого любой компонент, которому нужен список задач, может читать его из TasksContext:
export default function TaskList() {
const tasks = useContext(TasksContext);
// ...Чтобы обновить список задач, любой компонент может прочитать функцию dispatch из context и вызвать её:
export default function AddTask() {
const [text, setText] = useState('');
const dispatch = useContext(TasksDispatchContext);
// ...
return (
// ...
<button onClick={() => {
setText('');
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}}>Add</button>
// ...Компонент TaskApp больше не передаёт никакие обработчики событий вниз, и TaskList тоже не передаёт их в компонент Task. Каждый компонент читает нужный ему context:
//App.js
import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksContext, TasksDispatchContext } from './TasksContext.js';
export default function TaskApp() {
const [tasks, dispatch] = useReducer(
tasksReducer,
initialTasks
);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
<h1>Day off in Kyoto</h1>
<AddTask />
<TaskList />
</TasksDispatchContext>
</TasksContext>
);
}
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 }
];State по-прежнему "живёт" в верхнеуровневом компоненте TaskApp, которым управляет useReducer. Но теперь tasks и dispatch доступны каждому компоненту ниже в дереве через импорт и использование этих context.
Перенос всей связки в один файл
Вам не обязательно делать это, но можно ещё сильнее упростить компоненты, переместив и reducer, и context в один файл. Сейчас TasksContext.js содержит только два объявления context:
import { createContext } from 'react';
export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);Скоро этот файл станет тесным! Вы перенесёте reducer в тот же файл. Затем в этом же файле объявите новый компонент TasksProvider. Этот компонент объединит всё вместе:
- Он будет управлять state с помощью reducer.
- Он будет предоставлять оба context компонентам ниже.
- Он будет принимать
childrenкак prop, чтобы вы могли передавать ему JSX.
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
{children}
</TasksDispatchContext>
</TasksContext>
);
}Это убирает всю сложность и связку из компонента TaskApp:
//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>
);
}Вы также можете экспортировать функции, которые используют context из TasksContext.js:
export function useTasks() {
return useContext(TasksContext);
}
export function useTasksDispatch() {
return useContext(TasksDispatchContext);
}Когда компоненту нужно прочитать context, он может делать это через эти функции:
const tasks = useTasks();
const dispatch = useTasksDispatch();Это никак не меняет поведение, но позволяет позже разделить эти context ещё сильнее или добавить некоторую логику в эти функции. Теперь вся связка context и reducer находится в TasksContext.js. Это делает компоненты чистыми и аккуратными: они сосредоточены на том, что отображают, а не на том, откуда получают данные:
//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>
);
}Можно считать TasksProvider частью экрана, которая умеет работать с задачами, useTasks — способом читать их, а useTasksDispatch — способом обновлять их из любого компонента ниже в дереве.
Функции вроде useTasks и useTasksDispatch называются Custom Hooks. Ваша функция считается custom Hook, если её имя начинается с use. Это позволяет использовать внутри неё другие Hooks, например useContext.
По мере роста приложения у вас может появиться много таких пар context-reducer. Это мощный способ масштабировать приложение и поднимать state вверх без лишних усилий, когда нужно получать доступ к данным глубоко в дереве.
Вывод
- Вы можете объединить reducer с context, чтобы любой компонент мог читать и обновлять state выше себя.
- Чтобы передать state и функцию dispatch компонентам ниже:
- Создайте два context — для state и для функций dispatch.
- Передайте оба context из компонента, который использует reducer.
- Используйте любой из этих context в компонентах, которым нужно их читать.
- Ещё сильнее упростить компоненты можно, если перенести всю связку в один файл.
- Можно экспортировать компонент вроде
TasksProvider, который предоставляет context. - Можно также экспортировать custom Hooks вроде
useTasksиuseTasksDispatch, чтобы читать его.
- Можно экспортировать компонент вроде
- В вашем приложении может быть много таких пар context-reducer.
Передача данных глубоко через Context
Обычно информацию передают от родительского компонента к дочернему через props. Но передача props может стать громоздкой и неудобной, если их нужно прокидывать через множество промежуточных компонентов или если многим компонентам в приложении нужна одна и та же информация. *Context* позволяет родительскому компоненту сделать некоторую информацию доступной любому компоненту в дереве ниже него — на любой глубине — без явной передачи через props.
Escape Hatches
Некоторым вашим компонентам может понадобиться управлять системами вне React и синхронизироваться с ними. Например, вам может понадобиться установить фокус на input с помощью browser API, запускать и ставить на паузу видеоплеер, реализованный без React, или подключаться к удалённому серверу и слушать сообщения от него. В этой главе вы познакомитесь с escape hatches, которые позволяют "выйти" за пределы React и подключаться к внешним системам. Большая часть логики приложения и потоков данных не должна опираться на эти возможности.