HOW TO React

Масштабирование с помощью 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 }];
Preview

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:

  1. Создайте context.
  2. Поместите state и dispatch в context.
  3. Используйте 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. Этот компонент объединит всё вместе:

  1. Он будет управлять state с помощью reducer.
  2. Он будет предоставлять оба context компонентам ниже.
  3. Он будет принимать 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 компонентам ниже:
    1. Создайте два context — для state и для функций dispatch.
    2. Передайте оба context из компонента, который использует reducer.
    3. Используйте любой из этих context в компонентах, которым нужно их читать.
  • Ещё сильнее упростить компоненты можно, если перенести всю связку в один файл.
    • Можно экспортировать компонент вроде TasksProvider, который предоставляет context.
    • Можно также экспортировать custom Hooks вроде useTasks и useTasksDispatch, чтобы читать его.
  • В вашем приложении может быть много таких пар context-reducer.

Передача данных глубоко через Context

Обычно информацию передают от родительского компонента к дочернему через props. Но передача props может стать громоздкой и неудобной, если их нужно прокидывать через множество промежуточных компонентов или если многим компонентам в приложении нужна одна и та же информация. *Context* позволяет родительскому компоненту сделать некоторую информацию доступной любому компоненту в дереве ниже него — на любой глубине — без явной передачи через props.

Escape Hatches

Некоторым вашим компонентам может понадобиться управлять системами вне React и синхронизироваться с ними. Например, вам может понадобиться установить фокус на input с помощью browser API, запускать и ставить на паузу видеоплеер, реализованный без React, или подключаться к удалённому серверу и слушать сообщения от него. В этой главе вы познакомитесь с escape hatches, которые позволяют "выйти" за пределы React и подключаться к внешним системам. Большая часть логики приложения и потоков данных не должна опираться на эти возможности.

On this page