Skip to main content

Що таке useReducer в React?

useReducer - це хук React, який керує станом через чисту функцію-редюсер: приймає поточний стан і дію, повертає наступний стан.

Теорія

TL;DR

  • Аналогія: торговий автомат. Ти dispatch-иш дію (монета + кнопка), редюсер обчислює новий стан (видає снек), сам автомат не змінюється в процесі.
  • Головна різниця від useState: вся логіка переходів стану живе в одній функції поза компонентом, а не розкидана по обробниках подій.
  • Використовуй useReducer коли є 3+ типи оновлень або коли зміна одного поля зачіпає інше.
  • Для одного boolean чи простого лічильника - useState без зайвого шаблонного коду.
  • Редюсер - це звичайна функція. Для її тестування React-імпорти не потрібні.

Швидкий приклад

jsx
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

ХарактеристикаuseStateuseReducer
Найкраще дляПрості значення, незалежні поляСкладні об'єкти, кілька дій
Як оновлюєш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. Мутація стану в редюсері

jsx
// неправильно function badReducer(state, action) { state.count++; // мутує спільний стан напряму return state; // повертає те саме посилання }

React використовує поверхневе порівняння. Повернення того самого посилання на об'єкт - немає перерендеру. Завжди повертай новий об'єкт:

jsx
// правильно case 'increment': return { ...state, count: state.count + 1 };

2. dispatch всередині тіла render

jsx
// неправильно - нескінченний цикл function Counter() { const [state, dispatch] = useReducer(reducer, initialState); dispatch({ type: 'tick' }); // виконується кожен рендер, тригерить наступний }

Переноси dispatch в обробники подій або useEffect.

3. Забутий default case

jsx
// неправильно function reducer(state, action) { switch (action.type) { case 'increment': return { count: state.count + 1 }; // немає default - невідомі дії повернуть undefined } }

Завжди додавай default: return state. Без нього будь-яка нерозпізнана дія повертає undefined і ламає компонент так, що важко відстежити причину.

4. Передача функції-оновлювача як дії

jsx
// неправильно - звичка від useState dispatch(count => count + 1); // useReducer передає цю функцію як сам об'єкт дії // правильно dispatch({ type: 'increment' }); // обчислюй нове значення в редюсері

useReducer не приймає функції-оновлювачі. Другий аргумент редюсера - це завжди та дія, яку ти dispatch-нув.

5. useReducer для одного boolean

jsx
// зайве 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 } і редюсер відновлює знімок. Жодних додаткових бібліотек.

Приклади

Базовий: лічильник з трьома діями

jsx
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 з конкретним значенням - зміна тільки в редюсері, компонент не чіпаємо.

Середній: список задач з фільтрацією

jsx
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

jsx
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 ніколи не торкається стану напряму. Кожна частина має одне завдання - такий поділ добре масштабується коли компонент росте.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?