Масштабирование с помощью редюсера и контекста

Редюсеры позволяют объединить и упорядочить логику обновления состояния. Контекст позволяет передавать состояние глубоко в дочерние компоненты. Вы можете объединить редюсеры и контекст для управления комплексом состояний.

Вы узнаете

  • Как объединить редюсер и контекст
  • Как избежать передачи состояния и отправителя через пропсы
  • Как хранить логику контекста и состояния в отдельном файле

Объединение редюсера с контекстом

В этом примере из введения в редюсеры, состояние контролируется редюсером. Функция редюсер содержит в себе всю логику обновления состояния и объявлена в нижней части этого файла:

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>День в Киото</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: 'Тропа философа', done: true },
  { id: 1, text: 'Посетить храм', done: false },
  { id: 2, text: 'Выпить маття', done: false }
];

Редюсер помогает сделать обработчики событий краткими и лаконичными. Однако по мере роста вашего приложения вы можете столкнуться с другой трудностью. В настоящее время, состояние tasks и функция dispatch доступны только на верхнем уровне, в компоненте TaskApp. Что бы дать доступ к чтению и изменению списка задач другим компонентам, вам надо явно передать в дочернии компоненты в виде пропсов, текущее состояние и обработчики событий для его изменения.

Например, TaskApp передает список задач и обработчики событий в TaskList:

<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>

Также TaskList передает обработчики событий в Task:

<Task
task={task}
onChange={onChangeTask}
onDelete={onDeleteTask}
/>

Это небольшой пример который хорошо работает, но если у вас десятки или сотни компонентов между начальной точкой, где создаются состояние и функции, и конечной точкой, где они будут использованы, передача их через пропсы может быть крайне затруднительной!

И поэтому в качестве альтернативы передачи их через пропсы, вы можете поместить в контекст состояние tasks и функцию dispatch Благодаря этому дочернии компоненты TaskApp получить доступ к tasks и отправлять действия без передачи пропсов через компоненты которым они не нужны.

Вот как вы можешь объединить редюсер с контекстом:

  1. Создайте контекст.
  2. Поместите состояние и функцию отправитель в контекст.
  3. Используйте контекст в любых дочерних элементах.

Шаг 1: Создайте контекст

Хук useReducer возвращает текущее состояние tasks и функцию dispatch которая позволяет его обновлять:

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

Что бы передать их дальше по дереву вам надо создать два раздельных контекста:

  • TasksContext содержит текущий список задач.
  • TasksDispatchContext содержит функции которая позволяет компонентам отправлять действия(dispatch).

Экспортируйте их из разных файлов что бы потом импортировать в других файлах:

import { createContext } from 'react';

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);

В этом примере вы можете передать null как значение по умолчанию в каждый контекст. Актуальное значение передаст компонент TaskApp.

Шаг 2: Поместите состояние и отправителя в контекст

Сейчас вы можете импортировать контексты которые создавали в компонент TaskApp. Поместите tasks и dispatch возвращенные хуком useReducer() и передайте их во все дочерние компоненты:

import { TasksContext, TasksDispatchContext } from './TasksContext.js';

export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
// ...
return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
...
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}

На данный момент вы передаете информацию как через пропсы, так и в контексте:

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.Provider value={tasks}>
      <TasksDispatchContext.Provider value={dispatch}>
        <h1>День в Киото</h1>
        <AddTask
          onAddTask={handleAddTask}
        />
        <TaskList
          tasks={tasks}
          onChangeTask={handleChangeTask}
          onDeleteTask={handleDeleteTask}
        />
      </TasksDispatchContext.Provider>
    </TasksContext.Provider>
  );
}

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: 'Тропа философа', done: true },
  { id: 1, text: 'Посетить храм', done: false },
  { id: 2, text: 'Выпить маття', done: false }
];

На следующем шаге, вы избавитесь от пропсов.

Шаг 3: Используйте контекст в любом месте дерева

Теперь вам не надо передавать список задач или слушатели событий в дочернии компоненты:

<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
<h1>День в Киото</h1>
<AddTask />
<TaskList />
</TasksDispatchContext.Provider>
</TasksContext.Provider>

Вместо этого, все компоненты которым нужен список задач, могут получить его из TaskContext:

export default function TaskList() {
const tasks = useContext(TasksContext);
// ...

Что бы обновить список задач, любой компонент может получить функцию dispatch из контекста и использовать ее:

export default function AddTask() {
const [text, setText] = useState('');
const dispatch = useContext(TasksDispatchContext);
// ...
return (
// ...
<button onClick={() => {
setText('');
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}}>Добавить</button>
// ...

Компонент TaskApp никаких слушателей событий в дерево, как и TaskList не передает слушатели в компонентTask. Каждый компонент использует контекст который им нужен:

import { useState, useContext } from 'react';
import { TasksContext, TasksDispatchContext } from './TasksContext.js';

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

function Task({ task }) {
  const [isEditing, setIsEditing] = useState(false);
  const dispatch = useContext(TasksDispatchContext);
  let taskContent;
  if (isEditing) {
    taskContent = (
      <>
        <input
          value={task.text}
          onChange={e => {
            dispatch({
              type: 'changed',
              task: {
                ...task,
                text: e.target.value
              }
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Сохранить
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Редактировать
        </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
        });
      }}>
        Удалить
      </button>
    </label>
  );
}

Состояние все ещё “живёт” в родительском компоненте TaskApp, управляемое с помощью useReducer. Но его tasks и dispatch теперь доступны каждому дочернему компоненту путем импорта и использования этих контекстов.

Перемещение всех частей в один файл

Это необязательно делать, но вы можете еще больше упростить компоненты, переместив и редюсер, и контекст в один файл. В настоящее время, TasksContext.js содержит только два объявления контекста:

import { createContext } from 'react';

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);

Этот файл скоро будет переполнен! Вам надо переместить редюсер в этот же файл. Затем создать новый компонент TasksProvider в этом же файле. Этот компонент соберет все части воедино:

  1. Он будет управлять состоянием с помощью редюсера.
  2. Он будет передавать оба контекста дочерним компонентам.
  3. Он будет принимать children как пропс что бы вы могли передавать JSX в него.
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

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

Это убирает всю лишнюю логику из вашего компонента TaskApp:

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

export default function TaskApp() {
  return (
    <TasksProvider>
      <h1>День в Киото</h1>
      <AddTask />
      <TaskList />
    </TasksProvider>
  );
}

Вы так—же можете экспортировать функции которые используют контекст из TasksContext.js:

export function useTasks() {
return useContext(TasksContext);
}

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

Когда надо будет получить контекст в других компонентах, вы можете сделать это с помощью этих функций:

const tasks = useTasks();
const dispatch = useTasksDispatch();

Это никак не меняет поведение, но позволяет вам впоследствии разделить эти контексты еще больше или добавить некоторую логику в эти функции. Теперь все контексты и редюсеры находятся в TasksContext.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)}>
          Сохранить
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Редактировать
        </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
        });
      }}>
        Удалить
      </button>
    </label>
  );
}

Можно представить TasksProvider как часть интерфейса, которая знает, как работать с задачами, useTasks — как способ их чтения, а useTasksDispatch — как способ обновления из любого дочернего компонента.

Note

Функции похожие на useTasks и useTasksDispatch называются Пользовательские хуки. Ваша функция считается пользовательским хуком если ее название начинается с use. Это позволяет вам использовать другие хуки, такие как useContext, внутри неё.

По мере роста вашего приложения у вас может появиться много таких контекст-редюсер пар. Это мощный способ масштабировать приложение и передавать состояние без лишних усилий, когда вам нужно получить доступ к данным глубоко в дереве.

Recap

  • Вы можете комбинировать редюсер с контекстом, что бы любой компонент мог получить доступ к состоянию родительского компонента и изменять его
  • Чтобы предоставить состояние и функцию отправителя в дочернии компоненты, надо:
    1. Создать два контекста (для состояния и функций отправителей).
    2. Экспортируйте оба контекста из компонента в котором объявлен редюсер.
    3. Используйте оба контекста в компонентах где вам это нужно.
  • Можно ещё больше упростить компоненты, переместив всю логику создания в один файл.
    • Вы можете экспортировать компонент, например, TasksProvider, который предоставляет контекст.
    • Также можно экспортировать пользовательские хуки, такие как useTasks и useTasksDispatch, для доступа к данным.
  • В вашем приложении может быть много таких контекст-редюсер пар.