useReducer это хук React, который хранит состояние как useState, но переносит все способы его изменения в одну функцию, редьюсер. Компонент вызывает dispatch с объектом действия, например { type: 'increment' }, а React передаёт текущее состояние и это действие в редьюсер, который возвращает следующее состояние.
Кнопки не говорят, как меняется счётчик, а только что произошло. Вся арифметика живёт в reducer. Добавьте case 'double', который возвращает { count: state.count * 2 }, и кнопку, которая его отправляет, чтобы увидеть, как встраивается новый вид обновления.
Синтаксис
const [state, dispatch] = useReducer(reducer, initialArg, init); // init is optional
function reducer(state, action) {
// return the next state
}
reducerэто функция(state, action) => nextState. Объявляйте её вне компонента; ей ничего не нужно из области видимости компонента.initialArgэто начальное состояние, используется только при первом рендере.initнеобязателен. Если его передать, начальным состоянием будетinit(initialArg).stateэто текущее состояние для этого рендера.dispatch(action)отправляет действие в редьюсер и планирует рендер с результатом. Он стабилен: это одна и та же функция при каждом рендере, поэтому его безопасно передавать вниз или указывать в зависимостях.
Действием может быть любое значение, но по соглашению это объект со строкой type, описывающей, что произошло, плюс любые данные, нужные редьюсеру: { type: 'added', text: 'Buy milk' }.
Как написать редьюсер
Большинство редьюсеров это switch по action.type с одним case на каждый вид события. Каждый case возвращает совершенно новое состояние и никогда не меняет старое. Ветка default выбрасывает ошибку, поэтому опечатка вроде dispatch({ type: 'incremnet' }) падает громко, а не молча ничего не делает.
Называйте действия по тому, что сделал пользователь (added, toggled, deleted), а не по изменению состояния, которое вы задумали (setTodos). Тогда компонент читается как список событий, а редьюсер становится единственным местом, которое решает, что означает каждое событие.
Редьюсеры должны быть чистыми
Редьюсер это чистая функция: при одних и тех же состоянии и действии он возвращает один и тот же результат и больше ничего не делает. Значит, внутри никаких мутаций, запросов, таймеров, никаких Math.random() или Date.now(). React на это полагается. В разработке под StrictMode React вызывает ваш редьюсер дважды на каждое действие и сохраняет один результат, чтобы помочь заметить нечистый редьюсер. Превью здесь работает как продакшен-сборка, поэтому примеры на этой странице вызывают его один раз.
Чаще всего нарушают правило о мутациях. Добавление в state.todos через push и возврат state возвращает тот же объект, поэтому React не видит изменения и пропускает рендер; это та же ловушка, что описана на странице об обновлении массивов и объектов. Создавайте новые массивы и объекты через spread, map и filter.
// Wrong: mutates and returns the same object
case 'added':
state.todos.push(action.todo);
return state;
// Right: returns a new object with a new array
case 'added':
return { ...state, todos: [...state.todos, action.todo] };
Побочные эффекты, которые относятся к событию, например сохранение на сервер, ставьте в обработчик события рядом с dispatch. Побочные эффекты, которые относятся к состоянию, например синхронизацию с localStorage, ставьте в эффект.
Список задач на useReducer
Вот редьюсер за настоящей работой: добавление, переключение и удаление задач. console.log в начале редьюсера выводит каждое действие, и это удобный способ следить за изменением состояния при отладке. Это единственный побочный эффект, который в редьюсере обычно терпят, потому что он ничего не меняет; уберите его перед выпуском.
Добавьте задачу, отметьте флажок и удалите одну, затем прочитайте консоль: каждое изменение это одна строка с названием действия и его данными. Текст поля остаётся в useState, потому что он локален для поля и никакое другое событие его не трогает. Смешивать два хука в одном компоненте нормально.
id новой задачи создаётся в обработчике клика и передаётся в действии, поэтому редьюсер только копирует его. Если написать id: nextId++ внутри редьюсера, он станет нечистым: под StrictMode в разработке второй вызов пропустил бы id.
dispatch не меняет состояние сразу
dispatch работает как сеттер useState: он планирует рендер, а переменная state в текущем обработчике сохраняет старое значение. Чтобы сразу использовать новое состояние, вычислите его сами, вызвав редьюсер.
Нажмите несколько раз. Первая строка лога всегда отстаёт на шаг, а вторая совпадает с числом на кнопке после рендера. Поскольку редьюсер это обычная функция, вызывать его самостоятельно безопасно, и это ещё одно преимущество чистоты.
useState или useReducer
Оба хука хранят состояние, и всё, что написано одним, можно написать другим. Разница в том, где живёт логика обновления.
| useState | useReducer | |
|---|---|---|
| Логика обновления | В каждом обработчике событий | В одной функции-редьюсере |
| Подходит для | Нескольких независимых значений | Нескольких полей, которые меняются вместе от многих событий |
| Объём кода | Меньше для простого состояния | Больше в начале, меньше по мере роста событий |
| Тестирование | Тестировать через компонент | Тестировать редьюсер как обычную функцию |
| Отладка | Искать, какой обработчик его установил | Логировать каждое действие в одном месте |
Беритесь за useReducer, когда замечаете, что одно и то же состояние обновляется во многих обработчиках, когда одно событие должно менять несколько значений состояния, которые должны оставаться согласованными, или когда обработчик в основном состоит из логики следующего состояния. Для переключателя или текстового поля useState короче и понятнее. Перейти позже несложно: замените сеттеры на dispatch и перенесите логику в case.
Ленивая инициализация
Если записать начальное состояние как вызов функции, например useReducer(reducer, createInitialState('Ada')), этот вызов выполняется при каждом рендере, хотя React использует его результат только в первый раз. Если построение начального состояния дорогое или должно выводиться из пропса, передайте третий аргумент: функцию init. React вызовет init(initialArg) один раз, при первом рендере.
Пишите в поле: компонент рендерится при каждом нажатии клавиши, но createInitialState выводит сообщение только один раз. Теперь замените вызов на useReducer(reducer, createInitialState('Ada')) и снова попечатайте. Функция выполняется при каждом рендере, и React каждый раз после первого выбрасывает результат.
Передавайте саму функцию, а не результат её вызова. useReducer(reducer, 'Ada', createInitialState) ленивый; useReducer(reducer, createInitialState('Ada')) нет.
useReducer с контекстом
Редьюсер хорошо сочетается с контекстом, когда состояние нужно глубокому дереву. Поместите состояние и dispatch в контекст наверху, и любой компонент ниже сможет читать состояние или отправлять действия без пропсов, протянутых через каждый уровень. Поскольку dispatch никогда не меняется, компоненты, которые только отправляют действия, могут читать отдельный контекст для dispatch и не перерендериваться при изменении состояния.
import { createContext, useContext, useReducer } from 'react';
const TodosContext = createContext(null);
const TodosDispatchContext = createContext(null);
export function TodosProvider({ children }) {
const [todos, dispatch] = useReducer(todosReducer, []);
return (
<TodosContext value={todos}>
<TodosDispatchContext value={dispatch}>{children}</TodosDispatchContext>
</TodosContext>
);
}
function AddTodo() {
const dispatch = useContext(TodosDispatchContext);
return <button onClick={() => dispatch({ type: 'added', text: 'New' })}>Add</button>;
}
В React 19 объект контекста сам работает как провайдер (<TodosContext value={...}>); <TodosContext.Provider> тоже по-прежнему работает. Страница о useContext разбирает, как контекст доходит до компонентов и когда он их перерендеривает.
Часто задаваемые вопросы
Что такое useReducer в React?
Хук для состояния, обновления которого описываются как действия. Вы вызываете const [state, dispatch] = useReducer(reducer, initialState), а затем dispatch({ type: 'added' }) из обработчиков событий. React передаёт текущее состояние и действие в ваш reducer, и то, что он вернёт, становится следующим состоянием.
Когда использовать useReducer вместо useState?
Когда несколько событий обновляют одно и то же состояние связанными способами, когда следующее состояние зависит сразу от нескольких полей или когда логика обновления настолько длинная, что её хочется держать в одной тестируемой функции вне компонента. Для одного или двух независимых значений проще useState.
Почему редьюсер должен быть чистым?
React может вызвать ваш редьюсер больше одного раза для одного действия (StrictMode делает это намеренно в разработке) и ожидает каждый раз один и тот же результат. Поэтому редьюсер не должен мутировать состояние, загружать данные, ставить таймеры или читать случайные значения. Делайте это в обработчиках событий или эффектах.
dispatch обновляет состояние сразу?
Нет. Как и сеттер useState, dispatch планирует рендер. Внутри текущего обработчика события state всё ещё хранит старое значение; новое состояние появится в следующем рендере.
Что за третий аргумент у useReducer?
Необязательная функция init. Если её передать, React вычисляет начальное состояние как init(initialArg), и только при первом рендере. Это полезно, когда построение начального состояния дорогое или зависит от пропса.