Spec-Zone.ru › React 18

Извлечение логики состояния в редьюсер

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

Вы узнаете

  • Что такое функция-редуссер
  • Как переработать useState в useReducer
  • Когда использовать редуссер
  • Как правильно его написать

Объединение логики состояния с редуссером

По мере усложнения ваших компонентов становится сложнее наглядно увидеть все различные способы обновления состояния компонента. Например, компонент TaskApp ниже содержит массив tasks в состоянии и использует три разных обработчика событий для добавления, удаления и редактирования задач:

import { useState } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

export default function TaskApp() {
  const [tasks, setTasks] = useState(initialTasks);

  function handleAddTask(text) {
    setTasks([
      ...tasks,
      {
        id: nextId++,
        text: text,
        done: false,
      },
    ]);
  }

  function handleChangeTask(task) {
    setTasks(
      tasks.map((t) => {
        if (t.id === task.id) {
          return task;
        } else {
          return t;
        }
      })
    );
  }

  function handleDeleteTask(taskId) {
    setTasks(tasks.filter((t) => t.id !== taskId));
  }

  return (
    <>
      <h1>Prague itinerary</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

let nextId = 3;
const initialTasks = [
  {id: 0, text: 'Visit Kafka Museum', done: true},
  {id: 1, text: 'Watch a puppet show', done: false},
  {id: 2, text: 'Lennon Wall pic', done: false},
];

Каждый из его обработчиков событий вызывает setTasks для обновления состояния. По мере роста этого компонента увеличивается и количество логики состояния, разбросанного по нему. Чтобы уменьшить эту сложность и сохранить всю логику в одном легкодоступном месте, вы можете перенести эту логику состояния в одну функцию вне компонента, называемую «редуссером».

Редуссеры — это другой способ обработки состояния. Вы можете мигрировать от useState к useReducer в три шага:

  1. Переместите от установки состояния к отправке действий.
  2. Напишите функцию-редуссер.
  3. Используйте редуссер из вашего компонента.

Шаг 1: Переход от установки состояния к отправке действий

Ваши обработчики событий в настоящее время указывают, что делать, устанавливая состояние:

function handleAddTask(text) {
  setTasks([
    ...tasks,
    {
      id: nextId++,
      text: text,
      done: false,
    },
  ]);
}

function handleChangeTask(task) {
  setTasks(
    tasks.map((t) => {
      if (t.id === task.id) {
        return task;
      } else {
        return t;
      }
    })
  );
}

function handleDeleteTask(taskId) {
  setTasks(tasks.filter((t) => t.id !== taskId));
}

Удалите всю логику установки состояния. Что у вас останется, так это три обработчика событий:

  • handleAddTask(text) вызывается, когда пользователь нажимает «Добавить».
  • handleChangeTask(task) вызывается, когда пользователь переключает задачу или нажимает «Сохранить».
  • handleDeleteTask(taskId) вызывается, когда пользователь нажимает «Удалить».

Управление состоянием с помощью редуссеров немного отличается от непосредственной установки состояния. Вместо того, чтобы говорить React «что делать», устанавливая состояние, вы указываете «что только что сделал пользователь», отправляя «действия» из своих обработчиков событий. (Логика обновления состояния будет жить в другом месте!) Таким образом, вместо «установки tasks» через обработчик событий, вы отправляете действие «добавление/изменение/удаление задачи». Это более точно описывает намерение пользователя.

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,
  });
}

Объект, который вы передаёте в dispatch называется «действием»:

function handleDeleteTask(taskId) {
  dispatch(
    // "action" object:
    {
      type: 'deleted',
      id: taskId,
    }
  );
}

Это обычный JavaScript-объект. Вы сами решаете, что в него поместить, но, как правило, он должен содержать минимальную информацию о том, что произошло. (Вы добавите саму функцию dispatch на следующем шаге.)

Примечание

Объект действия может иметь любую форму.

По соглашению, принято давать ему строковое type значение, описывающее то, что произошло, и передавать дополнительную информацию в других полях. type значение специфично для компонента, поэтому в этом примере подойдёт как 'added', так и 'added_task'. Выберите имя, которое указывает, что произошло!

dispatch({
  // specific to component
  type: 'what_happened',
  // other fields go here
});

Шаг 2: Написание функции-редуссера

Функция-редуссер — это то место, куда вы поместите логику состояния. Она принимает два аргумента: текущее состояние и объект действия, и она возвращает следующее состояние:

function yourReducer(state, action) {
  // return next state for React to set
}

React установит состояние в то, что вы вернёте от редуссера.

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

  1. Объявите текущее состояние (tasks) в качестве первого аргумента.
  2. Объявите объект action в качестве второго аргумента.
  3. Верните следующее состояние из редуссера (которое React установит в состояние).

Вот вся логика установки состояния, перенесённая в функцию-редуссер:

function tasksReducer(tasks, action) {
  if (action.type === 'added') {
    return [
      ...tasks,
      {
        id: action.id,
        text: action.text,
        done: false,
      },
    ];
  } else if (action.type === 'changed') {
    return tasks.map((t) => {
      if (t.id === action.task.id) {
        return action.task;
      } else {
        return t;
      }
    });
  } else if (action.type === 'deleted') {
    return tasks.filter((t) => t.id !== action.id);
  } else {
    throw Error('Unknown action: ' + action.type);
  }
}

Поскольку функция-редуссер принимает состояние (tasks) в качестве аргумента, вы можете объявить её вне компонента. Это уменьшает уровень отступов и может сделать ваш код более читаемым.

Примечание

В приведенном выше коде используются инструкции if/else, но по соглашению внутри редуссеров следует использовать инструкции switch. Результат будет одинаковым, но инструкции switch могут быть более понятными на первый взгляд.

Мы будем использовать их в остальной части этой документации следующим образом:

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);
    }
  }
}

Мы рекомендуем заключать каждый case блок в фигурные скобки { и }, чтобы переменные, объявленные внутри разных case блоков, не конфликтовали друг с другом. Кроме того, инструкция case обычно должна завершаться return. Если вы забудете поставить return, код будет «проваливаться» к следующей case, что может привести к ошибкам!

Если вы ещё не знакомы с инструкциями switch, использование if/else вполне приемлемо.

Детальное рассмотрение

Почему редуссеры так называются?

Хотя редуссеры могут «уменьшать» количество кода внутри компонента, они фактически названы в честь операции reduce() над массивами.

Операция reduce() позволяет получить одно значение из множества элементов массива:

const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
  (result, number) => result + number
); // 1 + 2 + 3 + 4 + 5

Функция, которую вы передаёте в reduce известна как «редуссер». Она принимает результат до этого момента и текущий элемент, а затем возвращает следующий результат. Редуссеры React — пример того же подхода: они принимают состояние на данный момент и действие, а затем возвращают следующее состояние. Таким образом, они накапливают действия со временем в состоянии.

Вы даже можете использовать метод reduce() с initialState и массивом actions для вычисления конечного состояния, передав в него функцию-редуссер:

import tasksReducer from './tasksReducer.js';

let initialState = [];
let actions = [
  {type: 'added', id: 1, text: 'Visit Kafka Museum'},
  {type: 'added', id: 2, text: 'Watch a puppet show'},
  {type: 'deleted', id: 1},
  {type: 'added', id: 3, text: 'Lennon Wall pic'},
];

let finalState = actions.reduce(tasksReducer, initialState);

const output = document.getElementById('output');
output.textContent = JSON.stringify(finalState, null, 2);

Вам, вероятно, не придётся этого делать самостоятельно, но это похоже на то, что делает React!

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

Наконец, вам нужно подключить tasksReducer к вашему компоненту. Импортируйте хук useReducer из React:

import { useReducer } from 'react';

Затем вы можете заменить useState:

const [tasks, setTasks] = useState(initialTasks);

на useReducer следующим образом:

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

Хук useReducer похож на useState — вы должны передать ему начальное состояние, и он возвращает значение состояния и способ установки состояния (в данном случае, функцию отправки). Но он немного отличается.

Хук useReducer принимает два аргумента:

  1. Функцию-редуссер
  2. Начальное состояние

И возвращает:

  1. Значение состояния
  2. Функцию отправки (для «отправки» действий пользователя в редуссер)

Теперь всё подключено! Здесь редуссер объявлен в нижней части файла компонента:

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>Prague itinerary</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: 'Visit Kafka Museum', done: true},
  {id: 1, text: 'Watch a puppet show', done: false},
  {id: 2, text: 'Lennon Wall pic', done: false},
];

Если хотите, можете даже перенести редуссер в другой файл:

import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import tasksReducer from './tasksReducer.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>Prague itinerary</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

let nextId = 3;
const initialTasks = [
  {id: 0, text: 'Visit Kafka Museum', done: true},
  {id: 1, text: 'Watch a puppet show', done: false},
  {id: 2, text: 'Lennon Wall pic', done: false},
];

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

Сравнение useState и useReducer

Редуссеры не лишены недостатков! Вот несколько способов сравнить их:

  • Размер кода: Как правило, с useState вам нужно написать меньше кода изначально. С useReducer, вам нужно написать как функцию-редуссер, так и отправить действия. Однако, useReducer может помочь сократить код, если многие обработчики событий изменяют состояние похожим образом.
  • Читаемость: useState очень легко читается, когда обновления состояния простые. Когда они становятся более сложными, они могут раздувать код компонента и затруднять его сканирование. В этом случае useReducer позволяет чётко разделить как происходит логика обновления от того, что произошло в обработчиках событий.
  • Отладка: Когда у вас есть ошибка с useState, может быть сложно определить, где неправильно задано состояние и почему. С useReducer, вы можете добавить в консоль вывод в своём редуссере, чтобы увидеть каждое обновление состояния и почему оно произошло (из-за какого action). Если каждое action верно, вы будете знать, что ошибка находится в самой логике редуссера. Однако вам нужно пройти по большему количеству кода, чем с useState.
  • Тестирование: Редуссер — это чистая функция, не зависящая от вашего компонента. Это означает, что вы можете экспортировать и протестировать его отдельно, изолированно. Хотя обычно лучше тестировать компоненты в более реалистичной среде, для сложной логики обновления состояния это может быть полезно для проверки того, что ваш редуссер возвращает определённое состояние для определённого начального состояния и действия.
  • Личные предпочтения: Некоторые люди любят редуссеры, другие — нет. Это нормально. Это вопрос предпочтения. Вы всегда можете переключаться между useState и useReducer обратно: они эквивалентны!

Мы рекомендуем использовать редуссер, если вы часто сталкиваетесь с ошибками из-за неправильных обновлений состояния в каком-то компоненте и хотите внедрить больше структуры в его код. Вам не обязательно использовать редуссеры для всего: чувствуйте себя свободно смешивать и сочетать их! Вы даже можете useState и useReducer в одном компоненте.

Написание хороших редуссеров

Помните об этих двух советах при написании редуссеров:

  • Редукторы должны быть чистыми. Подобно функциям обновления состояния, редукторы выполняются во время отрисовки! (Действия очередируются до следующего рендера.) Это означает, что редукторы должны быть чистыми—одинаковые входные данные всегда приводят к одному и тому же результату. Они не должны отправлять запросы, планировать таймауты или выполнять побочные эффекты (операции, влияющие на элементы за пределами компонента). Они должны обновлять объекты и массивы без мутаций.
  • Каждое действие описывает одно пользовательское взаимодействие, даже если это приводит к нескольким изменениям данных. Например, если пользователь нажимает «Сброс» на форме с пятью полями, управляемыми редуктором, имеет больше смысла выполнить одно reset_form действие, а не пять отдельных set_field действий. Если вы регистрируете каждое действие в редукторе, этот журнал должен быть достаточно понятным, чтобы вы смогли восстановить, какие взаимодействия или ответы произошли и в каком порядке. Это помогает при отладке!

Создание лаконичных редукторов с помощью Immer

Точно так же, как с обновлением объектов и массивов в обычном состоянии, вы можете использовать библиотеку Immer, чтобы сделать редукторы более лаконичными. Здесь, useImmerReducer позволяет вам мутировать состояние с помощью push или arr[i] = присваивания:

{
  "dependencies": {
    "immer": "1.7.3",
    "react": "latest",
    "react-dom": "latest",
    "react-scripts": "latest",
    "use-immer": "0.5.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "devDependencies": {}
}

Редукторы должны быть чистыми, поэтому они не должны мутировать состояние. Но Immer предоставляет вам специальный draft объект, который безопасно мутировать. Под капотом Immer создаст копию вашего состояния с изменениями, которые вы внесли в draft. Именно поэтому редукторы, управляемые useImmerReducer, могут мутировать свой первый аргумент и не нуждаются в возвращении состояния.

Резюме

  • Для преобразования из useState в useReducer:
    1. Отправлять действия из обработчиков событий.
    2. Написать функцию-редуктор, которая возвращает следующее состояние для заданного состояния и действия.
    3. Заменить useState на useReducer.
  • Редукторы требуют написать немного больше кода, но они помогают при отладке и тестировании.
  • Редукторы должны быть чистыми.
  • Каждое действие описывает одно пользовательское взаимодействие.
  • Используйте Immer, если вы хотите писать редукторы в стиле мутации.

Попробуйте выполнить некоторые задачи

Задача 1 из 4:
Отправлять действия из обработчиков событий

В настоящее время обработчики событий в ContactList.js и Chat.js имеют // TODO комментарии. Именно поэтому ввод в поле ввода не работает, а нажатие кнопок не изменяет выбранного получателя.

Замените эти два // TODO на код для dispatch соответствующих действий. Чтобы увидеть ожидаемую форму и тип действий, проверьте редуктор в messengerReducer.js. Редуктор уже написан, поэтому вам не нужно его изменять. Вам нужно только отправить действия в ContactList.js и Chat.js.

import { useReducer } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';
import { initialState, messengerReducer } from './messengerReducer';

export default function Messenger() {
  const [state, dispatch] = useReducer(messengerReducer, initialState);
  const message = state.message;
  const contact = contacts.find((c) => c.id === state.selectedId);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={state.selectedId}
        dispatch={dispatch}
      />
      <Chat
        key={contact.id}
        message={message}
        contact={contact}
        dispatch={dispatch}
      />
    </div>
  );
}

const contacts = [
  {id: 0, name: 'Taylor', email: 'taylor@mail.com'},
  {id: 1, name: 'Alice', email: 'alice@mail.com'},
  {id: 2, name: 'Bob', email: 'bob@mail.com'},
];

© 2013–present Facebook Inc.
Licensed under the Creative Commons Attribution 4.0 International Public License.
https://18.react.dev/learn/extracting-state-logic-into-a-reducer

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API