Що таке useReducer в React?
useReducer - це хук React, який керує станом через чисту функцію-редюсер: приймає поточний стан і дію, повертає наступний стан.
Теорія
TL;DR
- Аналогія: торговий автомат. Ти dispatch-иш дію (монета + кнопка), редюсер обчислює новий стан (видає снек), сам автомат не змінюється в процесі.
- Головна різниця від
useState: вся логіка переходів стану живе в одній функції поза компонентом, а не розкидана по обробниках подій. - Використовуй
useReducerколи є 3+ типи оновлень або коли зміна одного поля зачіпає інше. - Для одного boolean чи простого лічильника -
useStateбез зайвого шаблонного коду. - Редюсер - це звичайна функція. Для її тестування React-імпорти не потрібні.
Швидкий приклад
import { useReducer } from 'react';
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 };
default: return state; // обов'язково обробляй невідомі дії
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
<p>Count: {state.count}</p> {/* починається з 0 */}
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
</>
);
}dispatch({ type: 'increment' }) надсилає дію в редюсер. Редюсер повертає { count: 1 }. React бачить новий стан і перерендерює компонент. Ось весь цикл.
Ключова різниця від useState
useState підходить для одного-двох незалежних значень. Коли стан - це об'єкт з кількома полями і зміна одного залежить від іншого, обробники подій починають нести забагато логіки. useReducer переносить цю логіку в одну функцію, яку можна читати, тестувати й розуміти окремо від компонента. Компонент залишається чистим - він тільки викликає dispatch, а не містить саму логіку оновлення.
Коли використовувати
- 1-2 незалежні значення без умов між ними -
useState. - Об'єкт стану з 3+ різними типами оновлень (додати, видалити, скинути, фільтрувати) -
useReducer. - Одне поле залежить від іншого (тогл тільки якщо не loading, інкремент тільки до певної межі) -
useReducer. - Хочеш тестувати переходи стану без рендеру компонента - тестуй редюсер як звичайну функцію напряму.
- Міграція з Redux -
useReducerслідує тому ж патерну.
useState проти useReducer
| Характеристика | useState | useReducer |
|---|---|---|
| Найкраще для | Прості значення, незалежні поля | Складні об'єкти, кілька дій |
| Як оновлюєш | setCount(n) | dispatch({ type: 'inc' }) |
| Де живе логіка | В обробниках подій | В одній функції-редюсері |
| Тестування | Через компонент | Редюсер як звичайна функція |
| Батчинг React 18 | Добре для більшості випадків | Краще для глибоких оновлень |
| Коли переходити | 1-2 сетери | 3+ дії, взаємозалежний стан |
Як React обробляє dispatch
React ставить dispatch-виклики в чергу і батчить їх у concurrent mode (React 18+). При наступному fiber commit React синхронно запускає редюсер з останнім станом і дією, обчислює наступний стан і планує перерендер тільки якщо результат відрізняється. Сама функція dispatch стабільна між рендерами - її не потрібно додавати в масиви залежностей.
Одна річ, яку я бачив як регулярну проблему в командах: редюсер виконується синхронно, тому fetch або API-виклики всередині нього неможливі. Редюсер повинен бути чистим.
Типові помилки
1. Мутація стану в редюсері
// неправильно
function badReducer(state, action) {
state.count++; // мутує спільний стан напряму
return state; // повертає те саме посилання
}React використовує поверхневе порівняння. Повернення того самого посилання на об'єкт - немає перерендеру. Завжди повертай новий об'єкт:
// правильно
case 'increment': return { ...state, count: state.count + 1 };2. dispatch всередині тіла render
// неправильно - нескінченний цикл
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
dispatch({ type: 'tick' }); // виконується кожен рендер, тригерить наступний
}Переноси dispatch в обробники подій або useEffect.
3. Забутий default case
// неправильно
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
// немає default - невідомі дії повернуть undefined
}
}Завжди додавай default: return state. Без нього будь-яка нерозпізнана дія повертає undefined і ламає компонент так, що важко відстежити причину.
4. Передача функції-оновлювача як дії
// неправильно - звичка від useState
dispatch(count => count + 1); // useReducer передає цю функцію як сам об'єкт дії
// правильно
dispatch({ type: 'increment' }); // обчислюй нове значення в редюсеріuseReducer не приймає функції-оновлювачі. Другий аргумент редюсера - це завжди та дія, яку ти dispatch-нув.
5. useReducer для одного boolean
// зайве
const [state, dispatch] = useReducer(reducer, { isOpen: false });
dispatch({ type: 'toggle' });
// простіше
const [isOpen, setIsOpen] = useState(false);
setIsOpen(prev => !prev);Більше шаблонного коду, ніж задача яку вирішуємо. Переходь на useReducer коли він справді потрібен.
Де зустрічається
- TodoMVC з документації React: один редюсер обробляє add, toggle і filter дії для списку задач.
- Async fetch: dispatch
loading,success,errorпо ходу запиту - чистіше ніж три окреміuseState. - Next.js App Router:
useReducer+useEffectдля клієнтського стану після серверних мутацій. - Zustand: всередині використовує схожий підхід з функціями-редюсерами для слайсів стану.
- Правило з документації React: якщо компонент потребує більше 3 сетерів або ти пишеш
if/switchлогіку в обробниках - час переходити наuseReducer.
Питання на співбесіді
Q: Коли вибрати useReducer замість useState?
A: Коли оновлення стану взаємозалежні. Наприклад, форма де при submit потрібно одночасно поставити loading: true і очистити error. З useState це два окремі виклики, які можуть розсинхронізуватись. З useReducer один dispatch({ type: 'submit' }) обробляє обидва атомарно.
Q: Що означає "чистий редюсер" і чому це важливо?
A: Чистий - читає тільки state і action, повертає новий об'єкт, без побічних ефектів. Саме чистота дозволяє React 18 безпечно батчити виклики і робить можливим відтворення переходів стану при відлагодженні.
Q: Як обробляти async операції з useReducer?
A: Редюсер залишається синхронним. Dispatch-иш { type: 'loading' } перед fetch, потім { type: 'success', payload: data } або { type: 'error', payload: message } в .then/.catch. useEffect відповідає за async частину, useReducer - за переходи стану.
Q: Яка різниця між useReducer і Redux?
A: useReducer локальний для одного компонента - немає глобального store, провайдера, middleware. Redux додає глобальний store, devtools і middleware. Для логіки одного компонента useReducer достатньо; для стану що потрібен в багатьох місцях - комбінують з Context або беруть Redux/Zustand.
Q: (Senior) Як реалізувати оптимістичне оновлення з відкатом через useReducer?
A: Зберігаєш знімок поточного стану перед дією. Dispatch { type: 'optimistic_update', data: newData } - показуєш зміну одразу. При помилці API dispatch { type: 'rollback', snapshot: previousState } і редюсер відновлює знімок. Жодних додаткових бібліотек.
Приклади
Базовий: лічильник з трьома діями
import { useReducer } from 'react';
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
case 'reset': return { count: 0 };
default: return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset' })}>Скинути</button>
</div>
);
}Три дії в одному редюсері. Додати четверту - наприклад set з конкретним значенням - зміна тільки в редюсері, компонент не чіпаємо.
Середній: список задач з фільтрацією
import { useReducer } from 'react';
const initialState = { todos: [], filter: 'all' };
function todosReducer(state, action) {
switch (action.type) {
case 'add_todo':
return {
...state,
todos: [...state.todos, { id: Date.now(), text: action.text, done: false }]
};
case 'toggle':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
)
};
case 'set_filter':
return { ...state, filter: action.filter };
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todosReducer, initialState);
const visible = state.filter === 'all'
? state.todos
: state.todos.filter(t => t.done === (state.filter === 'done'));
return (
<div>
<input
onKeyDown={e => {
if (e.key === 'Enter') {
dispatch({ type: 'add_todo', text: e.target.value });
e.target.value = '';
}
}}
placeholder="Додати задачу..."
/>
<ul>
{visible.map(todo => (
<li
key={todo.id}
onClick={() => dispatch({ type: 'toggle', id: todo.id })}
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
>
{todo.text}
</li>
))}
</ul>
<button onClick={() => dispatch({ type: 'set_filter', filter: 'all' })}>Всі</button>
<button onClick={() => dispatch({ type: 'set_filter', filter: 'done' })}>Виконані</button>
</div>
);
}Три пов'язаних поля - todos, filter і обчислений visible - оновлюються в одному місці. Спробуй написати те саме на useState і порахуй скільки місць прийдеться чіпати для кожної нової фічі.
Складний: async fetch зі станами loading і error
import { useReducer, useEffect } from 'react';
const initialState = { data: null, loading: false, error: null };
function asyncReducer(state, action) {
switch (action.type) {
case 'loading':
return { ...state, loading: true, error: null };
case 'success':
return { data: action.payload, loading: false, error: null };
case 'error':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
}
function UserProfile({ userId }) {
const [state, dispatch] = useReducer(asyncReducer, initialState);
useEffect(() => {
dispatch({ type: 'loading' });
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => dispatch({ type: 'success', payload: data }))
.catch(err => dispatch({ type: 'error', payload: err.message }));
}, [userId]);
if (state.loading) return <p>Завантаження...</p>;
if (state.error) return <p>Помилка: {state.error}</p>;
return <pre>{JSON.stringify(state.data, null, 2)}</pre>;
}Редюсер ніколи не торкається fetch. useEffect ніколи не торкається стану напряму. Кожна частина має одне завдання - такий поділ добре масштабується коли компонент росте.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.