Первоначально опубликовано на https://www.wisdomgeek.com 1 сентября 2020 г.

Основываясь на введении в хуки React из нашего предыдущего поста о понимании хуков React (useState и useEffect), мы рассмотрим ловушку useReducer в этом посте. Перехватчик useReducer может быть альтернативой useState (фактически, useState использует useReducer внутри себя). Прежде чем перейти к использованию хука useReducer, мы должны понять, что подразумевается под редюсером.

Что такое редуктор?

Если вы пришли из редукции фона, вы, вероятно, можете пропустить этот раздел. Но для тех, кто этого не делает, давайте сначала разберемся, что такое редюсер и зачем он нужен. Затем мы перейдем к хуку useReducer.

Первый принцип, который мы должны помнить, прежде чем переходить к редюсеру, - это то, что состояние представлено в виде единого неизменяемого дерева. Поэтому всякий раз, когда мы вносим изменения в состояние, это явное изменение. Обычно мы используем функцию setState для изменения состояния. При использовании redux мы не будем вносить эти изменения непосредственно в состояние. Вместо этого мы будем использовать редукторы, которые представляют собой функции, определяющие, как изменить состояние приложения. Для внесения изменений в состояние приложения мы будем вызывать эти редукторы с действием, чтобы указать, что произошло.

Давайте рассмотрим простой встречный пример:

function Counter({initialCount}) {
  const [count, setCount] = useState(initialCount);
  return (
    <>
      Count: {count}
      <button onClick={() => setCount(initialCount)}>Reset</button>
      <button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
    </>
  );
}

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

function reducer(count, action) {
  switch (action) {
    case 'increment':
      return count + 1;
    case 'decrement':
      return count - 1;
  }
}

Действие - это минимальное представление изменения данных (или состояния) приложения.

Зачем нужны редукторы?

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

(state, action) => newState

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

В целом, все эти свойства функции редуктора делают ее идеальной для независимого и изолированного тестирования изменений состояния. Один и тот же ввод всегда должен возвращать один и тот же вывод.

Действие в функции редуктора

Несмотря на то, что мы коснулись действия выше, это была упрощенная версия того, как выглядит действие. Иногда мы хотим передать значение вместе с действием. Если бы мы увеличили на 5 вместо 1, наш предыдущий пример потребовал бы совсем другого действия.

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

Собрав все это вместе, наш обновленный редуктор теперь будет выглядеть так:

const initialState = {count: 0};

function countReducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}
const newState = countReducer(initialState, 'increment') // returns {count: 1}
countReducer(newState , 'decrement') // returns {count: 0}

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

Хук useReducer в React

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

Еще одно преимущество редуктора - отсутствие необходимости вручную передавать props от родительских компонентов к дочерним компонентам. Это возможно, потому что вся логика, связанная с состоянием, определена внутри функции reducer. А дочернему компоненту нужно только вызвать функцию редуктора с соответствующим действием.

Прежде чем мы начнем использовать ловушку useReducer, нам нужно определить reducer. Мы уже сделали это выше для нашего встречного примера. Затем мы можем сократить вызов useState с помощью useReducer и передать ему редуктор и начальное состояние, которое мы хотим назначить.

const initialState = {count: 0};
const [state, dispatch] = useReducer(reducer, initialState);

Как и useState, useReducer возвращает массив из двух переменных. Первый относится к текущему состоянию приложения, а второй - это функция диспетчеризации, которую мы можем использовать для отправки действий редуктору. Вызов функции диспетчеризации изменит состояние приложения в зависимости от действия, с которым мы его вызываем. Таким образом, наш пример счетчика будет преобразован в следующий код с помощью хука useReducer:

const initialState = {count: 0};

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    case 'reset':
      return initialState;
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'reset'})}>Reset</button>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

Также важно отметить, что React гарантирует, что вызовы функции диспетчеризации стабильны и не изменятся при повторном рендеринге. Поэтому нам не нужно помещать его в список зависимостей useEffect.

Применение хука useReducer к нашему приложению To-Do list

Давайте теперь применим ловушку к нашему приложению со списком дел, которое мы создали в предыдущем сообщении в блоге.

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

const itemsReducer = (state, action) => {
  switch (action.type) {
    case 'POPULATE_ITEMS':
      return action.items;
    case 'ADD_ITEM':
      return [...state, action.item];
    case 'REMOVE_ITEM':
      return state.filter((item) => item !== action.itemToBeDeleted);
    default:
      return state;
  }
};

Эти три действия соответствуют выборке данных, добавлению элемента и удалению элемента. Они говорят сами за себя о том, что мы пытаемся сделать здесь в отношении типа действия, которое мы получаем. Затем мы начнем использовать этот редуктор в нашем компоненте приложения. Мы заменим useState нашим хуком useReducer

const [items, itemsDispatch] = useReducer(itemsReducer, []);

Мы можем назвать первую переменную (состояние) как угодно. Лучше более четко указать, на что он ссылается, поскольку в приложении может быть несколько редукторов. Поэтому мы не назвали его состоянием, как в нашем примере раньше.

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

useEffect(() => {
    const items = JSON.parse(localStorage.getItem('items'));
    if (items) {
      setItems(items);
    }
  }, []);

У нас больше нет доступа к setItems. Но мы создали действие POPULATE_ITEMS в нашем редукторе, чтобы заполнить элементы, которые можно здесь использовать. Поэтому вместо этого мы вызовем нашу функцию диспетчеризации:

useEffect(() => {
  const items = JSON.parse(localStorage.getItem('items'));
  if (items) {
    itemsDispatch({ type: 'POPULATE_ITEMS', items });
  }
}, []);

Когда мы вызываем эту функцию диспетчеризации, она вызывает наш редуктор с типом действия POPULATE_ITEMS. И поскольку мы передали элементы (используя сокращенную запись), редуктор элементов возвращает эти элементы и сохраняет их в состоянии приложения.

Для другого useEffect, где мы сохраняли элементы в состояние, нам не нужно ничего делать, поскольку мы не выполняли никаких манипуляций с состоянием.

Затем мы сделаем то же самое для других действий, которые у нас есть, а именно добавим элемент и удалим его.

const addItem = (item) => {
  // setItems([...items, item]);
  // becomes:
  itemsDispatch({ type: 'ADD_ITEM', item });
}

const removeItem = (itemToBeDeleted) => {
    // setItems(items.filter((item) => itemToBeDeleted !== item));
    // becomes
    itemsDispatch({ type: 'REMOVE_ITEM', itemToBeDeleted });
};

На этом мы завершаем рефакторинг для использования ловушки useReducer в нашем коде.

Вы можете найти изменения кода здесь и окончательный код здесь.

Мы поговорим об useContext в следующем посте, и на этом наше приложение с делами будет завершено. Если есть что-то еще, о чем вы хотите, чтобы мы рассказали, оставьте комментарий ниже, чтобы сообщить нам об этом!