useReducer: reducer как чистая функция

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

Reducer — чистая функция (state, action) => newState: по текущему состоянию и описанию действия она вычисляет следующее состояние, ничего не мутируя и не обращаясь к внешнему миру.

Когда useState становится тесным

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

const [state, dispatch] = React.useReducer(reducer, initialState);
// в обработчике:
dispatch({ type: "increment", by: 2 });

Редьюсер — это чистая функция (запускаемый пример)

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

function reducer(state, action) {
  switch (action.type) {
    case "add":
      return { ...state, count: state.count + 1, items: [...state.items, action.name] };
    case "remove":
      return {
        ...state,
        count: Math.max(0, state.count - 1),
        items: state.items.slice(1),
      };
    case "reset":
      return { count: 0, items: [] };
    default:
      return state;
  }
}

let state = { count: 0, items: [] };
const actions = [
  { type: "add", name: "Книга" },
  { type: "add", name: "Ручка" },
  { type: "remove" },
  { type: "add", name: "Тетрадь" },
];

for (const a of actions) {
  state = reducer(state, a);
  console.log(a.type, "=>", JSON.stringify(state));
}

console.log("reset =>", JSON.stringify(reducer(state, { type: "reset" })));

Вывод:

add => {"count":1,"items":["Книга"]}
add => {"count":2,"items":["Книга","Ручка"]}
remove => {"count":1,"items":["Ручка"]}
add => {"count":2,"items":["Ручка","Тетрадь"]}
reset => {"count":0,"items":[]}

Обратите внимание: каждое действие возвращает новый объект (через ...state и slice), а не мутирует старый. Это та же иммутабельность, что и в useState — React сравнивает ссылки.

Чем это лучше

  • Логика в одном месте. Все переходы видны в редьюсере, а не размазаны по обработчикам.
  • Тестируемость. Редьюсер — чистая функция: подал state и action, проверил результат, без рендера и моков.
  • Предсказуемость. Состояние меняется только через явные действия с понятными именами.
  • Стабильный dispatch. React гарантирует, что ссылка на dispatch не меняется, — её безопасно передавать в memo-детей без useCallback.

useReducer или useState?

Берите useStateБерите useReducer
1–2 независимых значениянесколько связанных полей
простые присваиваниясложные переходы, зависящие от прошлого состояния
логика умещается в обработчикехочется собрать переходы и протестировать их

Итог

  • useReducer описывает переходы состояния чистой функцией (state, action) => newState.
  • Редьюсер не зависит от React — его легко прогнать и протестировать в обычном JS.
  • Возвращайте новый объект состояния, не мутируйте старый.
  • Подходит для сложного связанного состояния; для пары простых значений хватит useState.
Проверьте себя
1. Что такое reducer?
AХук для запросов к API
BЧистая функция (state, action) => newState без мутаций и побочных эффектов
CКомпонент-обёртка
DМетод массива
2. Почему reducer легко тестировать?
AОн работает только в браузере
BОн не зависит от React: подал state и action — проверил результат
CОн автоматически генерирует тесты
DОн использует моки DOM
3. Что должен возвращать reducer при изменении состояния?
AТот же объект, изменённый на месте
BНовый объект состояния, не мутируя старый
CТолько изменённое поле
Dundefined, чтобы сбросить
Поддержать проект