Масштабування з використанням контексту та редюсера

Редюсери дають змогу об’єднати логіку оновлення стану компонента. Контекст дає змогу передавати інформацію у глибину до інших компонентів. Можна поєднати редюсери і контекст, щоб управляти станом складного інтерфейсу.

You will learn

  • Як поєднати з контекстом
  • Як уникнути передачі стану та диспатча через пропси
  • Як зберігати логіку контексту і стану в окремому файлі

Поєднання редюсера з контекстом

Як ми вже бачили, у прикладі з цього розділу, стан керується редюсером. Функція редюсера містить всю логіку оновлення стану і оголошена в кінці файлу:

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} dispatch={dispatch} />
```js
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>

А TaskList передає обробники компоненту Task:

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

У невеликому прикладі це добре працює, але якщо у вас є десятки або сотні вкладених компонентів, передавати таким чином весь стан і функції може бути досить неприємно!

Тому, як альтернатива передачі через пропси, ви можете помістити і стан tasks, і функцію dispatch в контекст. Таким чином, будь-який компонент, що знаходиться нижче TaskApp у дереві, може мати доступ та надсилати події, уникнувши передачі пропсів через безліч компонентів.

Ось як можна поєднати редюсер із контекстом:

  1. Створіть контекст.
  2. Розмістіть стан і диспетчер у контексті.
  3. Використовуйте контекст будь-де в дереві компонентів.

Крок 1: Створення контексту

Хук useReducer повертає поточний стан tasks та функцію dispatch, яка дає змогу оновлювати цей стан:

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

Щоб передати їх далі по дереву, створить два окремих контексти:

  • TasksContext надає поточний перелік завдань.
  • TasksDispatchContext надає функцію, яка дає змогу компонентам надсилати події.

Експортуйте їх з окремого файлу, щоб згодом імпортувати в інших місцях:

import { createContext } from 'react';

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

Тут ви передаєте null як значення за замовчуванням для обох контекстів. Дійсні значення будуть надані компонентом TaskApp.

Крок 2: Помістіть стан і функцію dispatch у контекст

Тепер ви можете імпортувати обидва контексти у ваш компонент 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 }
];

На наступному етапі видалимо передачу пропсів.

Step 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

  • Ви можете об’єднати редюсер з контекстом, щоб будь-який компонент міг мати доступ та оновлювати стан у компонентах вищих за ієрархією.
  • Щоб надати стан і функцію dispatch компонентам нижче:
    1. Створіть два контексти (для стану і для функцій dispatch).
    2. Надайте обидва контексти компоненту, який використовує редюсер.
    3. Використовуйте будь-який контекст у компонентах, яким потрібно мати ці дані.
  • Щоб ще більше спростити компоненти, виносьте ці налаштування в один файл.
    • Експортуйте компонент, наприклад TasksProvider, який надає контекст.
    • Ви також можете експортувати користувацькі хуки, як-от useTasks та useTasksDispatch, для доступу до контексту.
  • У додатку може бути багато схожих на цю пар контекст-редюсерів.