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Компонент-обёртка
CЧистая функция (state, action) => newState без мутаций и побочных эффектов
DМетод массива
2. Почему reducer легко тестировать?
AОн работает только в браузере
BОн автоматически генерирует тесты
CОн использует моки DOM
DОн не зависит от React: подал state и action — проверил результат
3. Что должен возвращать reducer при изменении состояния?
AНовый объект состояния, не мутируя старый
BТот же объект, изменённый на месте
CТолько изменённое поле
Dundefined, чтобы сбросить