Skip to main content

Redux Thunk vs Redux Saga - яка різниця?

Redux Thunk vs Redux Saga - Thunk дозволяє action creator-ам повертати функції, які викликають dispatch після асинхронної роботи; Saga використовує функції-генератори (generators), які yield-ять об'єкти-ефекти (effects), і middleware їх інтерпретує та виконує.

Теорія

TL;DR

  • Thunk: action creator повертає функцію, яка викликає dispatch після завершення async-роботи
  • Saga: функція-генератор yield-ить дескриптори ефектів (call, put, take), а middleware їх виконує
  • Аналогія: Thunk - кнопка автомата з напоями, натискаєш і чекаєш на результат. Saga - бригадир на конвеєрі, розподіляє завдання, контролює кожен крок, перенаправляє при збоях
  • Вибір: один API-запит або базовий CRUD? Thunk. Скасування, polling, race conditions, debounce? Saga
  • Розмір: Thunk ~1KB, Saga ~20KB з runtime

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

Обидва варіанти обробляють одну й ту саму дію FETCH_USER_REQUEST:

javascript
// Thunk - action creator повертає функцію, а не plain об'єкт const fetchUser = (id) => (dispatch) => { dispatch({ type: 'FETCH_USER_REQUEST' }); fetch(`/api/user/${id}`) .then(res => res.json()) .then(user => dispatch({ type: 'FETCH_USER_SUCCESS', payload: user })) .catch(err => dispatch({ type: 'FETCH_USER_FAILURE', payload: err })); }; // dispatch(fetchUser(123)) - thunk middleware бачить функцію і викликає її
javascript
// Saga - генератор yield-ить ефекти, middleware їх виконує import { call, put, takeEvery } from 'redux-saga/effects'; function* fetchUserSaga(action) { try { const user = yield call(fetch, `/api/user/${action.payload}`); yield put({ type: 'FETCH_USER_SUCCESS', payload: user }); } catch (error) { yield put({ type: 'FETCH_USER_FAILURE', payload: error }); } } // takeEvery('FETCH_USER_REQUEST', fetchUserSaga) - слухає і запускає на кожну дію

З Thunk функція виконується одразу при диспатчингу. З Saga call(fetch, ...) - це звичайний JavaScript об'єкт. Middleware читає його, викликає fetch, потім відновлює генератор з результатом.

Ключова різниця

Thunk middleware перевіряє typeof action === 'function'. Якщо true, викликає action(dispatch, getState) синхронно і повертає результат. Це весь код Redux Thunk, буквально менше 10 рядків. Saga запускає генератори через next() у циклі, де кожен yield-нутий об'єкт є інструкцією: call означає "виконай цю async-функцію", put - "відправ цю дію", take - "чекай на цей тип дії". Оскільки ці інструкції є plain об'єктами, тести Saga можна писати без жодного mock-ування, просто перевіряй що було yield-нуто.

Коли що використовувати

  • Один API-запит без координації: Thunk, менше файлів, не треба вчити синтаксис генераторів
  • Debounce для пошукового поля: у Saga є вбудований ефект debounce, Thunk потребує зовнішньої утиліти або ручного таймера
  • Декілька паралельних запитів: ефект all([...]) у Saga вирішує це чисто
  • Race conditions (перший API виграє, інший скасовується): race({...}) у Saga - три рядки коду
  • Скасування запиту при навігації геть: у Saga є вбудований cancel(), Thunk потребує ручного AbortController
  • Polling (перевіряти сервер кожні N секунд): паттерн while(true) з delay() у Saga, у Thunk це виходить незграбно
  • Застосунок з менш ніж 10 async-діями: Thunk, без зайвого ускладнення

Таблиця порівняння

ОзнакаRedux ThunkRedux Saga
МеханізмДиспатчена функція виконує async-кодГенератор yield-ить ефекти, middleware виконує
Asyncasync/await або promises всередині thunkyield call() для API, yield put() для dispatch
Контроль потокуЛінійний, ручний try/catchВбудовані fork, join, race, cancel, retry
ТестуванняMock dispatch і getStateПеревірка yield-нутих об'єктів, mock не потрібен
Розмір~1KB~20KB + runtime
Поріг входу5 хвилин1-2 години (генератори + модель ефектів)
Debounce/throttleЗовнішня бібліотека або ручний таймерВбудовані ефекти debounce і throttle
Для чогоПростий CRUD, базові запитиReal-time застосунки, складна оркестрація

Як middleware це обробляє

Thunk перехоплює store.dispatch(action). Якщо typeof action === 'function', викликає action(dispatch, getState) синхронно і повертає результат. Це майже весь вихідний код Redux Thunk.

Saga middleware запускає кореневий генератор і драйвить його через next(resolvedValue). Коли генератор yield-ить call(fn, args), middleware викликає fn(...args), чекає поки promise вирішиться, потім відновлює генератор з результатом. Якщо promise відхиляється - кидає помилку в генератор на місці yield. put(action) ставить dispatch у чергу. take(pattern) призупиняє генератор до появи відповідної дії. Саме це покрокове виконання дає Saga можливість паузити, скасовувати і тестуватися без mock-ів.

Типові помилки

1. Thunk: ігнорування getState для залежної логіки

javascript
// Неправильно - запит без перевірки кешу const fetchUser = (id) => async (dispatch) => { const user = await api.get(id); dispatch({ type: 'FETCH_USER_SUCCESS', payload: user }); }; // Правильно - уникнути дублікатних запитів const fetchUser = (id) => async (dispatch, getState) => { if (getState().users[id]) return; // вже є у store const user = await api.get(id); dispatch({ type: 'FETCH_USER_SUCCESS', payload: user }); };

2. Saga: виклик API без yield call()

javascript
// Неправильно - синхронний виклик, api.getUser повертає Promise, не дані function* fetchUserSaga(action) { const user = api.getUser(action.payload); // не yield-нуто yield put({ type: 'FETCH_USER_SUCCESS', payload: user }); // user - це Promise, не об'єкт } // Правильно function* fetchUserSaga(action) { const user = yield call(api.getUser, action.payload); yield put({ type: 'FETCH_USER_SUCCESS', payload: user }); }

3. Saga: takeEvery що тригериться на власні dispatch-и (нескінченний цикл)

javascript
// Неправильно - якщо handler диспатчить ту саму дію, вона знову запускається function* watchFetch() { yield takeEvery('FETCH_DATA', handleFetch); } // Правильно - takeLatest скасовує попереднє завдання при кожній новій дії function* watchFetch() { yield takeLatest('FETCH_DATA', handleFetch); }

4. Thunk: відсутність cleanup після unmount компонента

javascript
// Неправильно - диспатч у store після знищення компонента const fetchUser = (id) => async (dispatch) => { const user = await api.get(id); dispatch({ type: 'FETCH_USER_SUCCESS', payload: user }); // React попередження, якщо вже unmounted }; // Правильно - AbortController сигналізує fetch про зупинку const fetchUser = (id, signal) => async (dispatch) => { try { const user = await api.get(id, { signal }); dispatch({ type: 'FETCH_USER_SUCCESS', payload: user }); } catch (err) { if (err.name !== 'AbortError') dispatch({ type: 'FETCH_USER_FAILURE', payload: err }); } }; // У компоненті: const ctrl = new AbortController(); // dispatch(fetchUser(id, ctrl.signal)); return () => ctrl.abort();

Використання на практиці

  • Create React App і Redux Toolkit поставляються з Thunk за замовчуванням
  • Stream Chat SDK використовує Saga для оркестрації WebSocket і логіки перепідключення
  • Next.js застосунки зазвичай беруть Thunk для SSR (getServerSideProps), Saga для WebSocket або polling
  • Electron-застосунки застосовують Saga для управління race conditions при роботі з файловою системою
  • У більшості production React-проектів, які я переглядав, команди починають з Thunk і додають Saga тільки там, де конкретний flow стає достатньо складним щоб виправдати overhead

Додаткові питання

Q: Як Saga обробляє відхилення promise всередині call()?
A: Коли promise з call() відхиляється, Saga кидає помилку в генератор на місці yield. Блок try/catch навколо yield call(...) перехоплює її - так само як це робить async/await.

Q: Чи можна скасувати Thunk що вже виконується?
A: Нативно - ні. Потрібно вручну додати AbortController і викликати controller.abort(), зазвичай у cleanup-функції useEffect. cancel() і takeLatest у Saga вирішують це без додаткового коду.

Q: Яка різниця в продуктивності для 100 паралельних запитів?
A: Thunk створює 100 незалежних функцій без координації. Saga запускає легковагові tasks з меншим споживанням пам'яті на task, але генератори мають власний overhead. Для більшості застосунків різниця незначна, проте структурований паралелізм Saga запобігає неконтрольованим tasks.

Q: Яка різниця між fork і spawn у Saga?
A: fork створює task, прив'язаний до батьківського - якщо дочірній кидає помилку, вона передається вгору. spawn створює відокремлений task - якщо він кидає помилку, батьківська saga продовжує роботу. Використовуй spawn для фонової роботи в стилі fire-and-forget.

Q: (Senior) Як select() взаємодіє з блокуючим ефектом take()?
A: select() - неблокуючий ефект, він читає поточний стан store одразу і відновлює генератор у тому ж тику. take(pattern) призупиняє генератор до появи відповідної дії. Якщо написати yield take('X'); yield select(...), то select читає стан після того, як X вже оброблено редюсерами. getState() у Thunk теж синхронний, але виконується всередині звичайної функції, а не призупиненого генератора, тому семантика часу відрізняється.

Q: Що ламається при міграції з Thunk на Saga?
A: Компоненти, які роблять await dispatch(thunkAction()) і покладаються на повернений promise. Диспатч у Saga не повертає значень, тому такий патерн перестає працювати. Потрібно переробити архітектуру під слухання дій, callback-канали або eventChannel.

Приклади

Базовий: запит користувача за дією

javascript
// Thunk - fetchUser повертає функцію, а не plain action object const fetchUser = (id) => async (dispatch) => { dispatch({ type: 'FETCH_USER_REQUEST' }); try { const res = await fetch(`/api/users/${id}`); const user = await res.json(); dispatch({ type: 'FETCH_USER_SUCCESS', payload: user }); } catch (err) { dispatch({ type: 'FETCH_USER_FAILURE', payload: err.message }); } }; dispatch(fetchUser(42)); // thunk middleware перехоплює і викликає внутрішню функцію
javascript
// Saga - генератор обробляє той самий flow декларативно import { call, put, takeLatest } from 'redux-saga/effects'; function* fetchUserSaga({ payload: id }) { try { const res = yield call(fetch, `/api/users/${id}`); const user = yield call([res, 'json']); // виклик методу на об'єкті yield put({ type: 'FETCH_USER_SUCCESS', payload: user }); } catch (err) { yield put({ type: 'FETCH_USER_FAILURE', payload: err.message }); } } export function* watchUsers() { yield takeLatest('FETCH_USER_REQUEST', fetchUserSaga); // takeLatest скасовує попередній fetch якщо прийде новий REQUEST }

takeLatest автоматично скасовує попередній fetch при надходженні нового FETCH_USER_REQUEST. Для такої ж поведінки у Thunk потрібен ручний AbortController.

Середній: пошуковий рядок з debounce

javascript
// Thunk - ручний debounce з таймером на рівні модуля let searchTimer; const searchUsers = (query) => (dispatch) => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { dispatch({ type: 'SEARCH_USERS_REQUEST' }); api.searchUsers(query) .then(users => dispatch({ type: 'SEARCH_USERS_SUCCESS', payload: users })) .catch(() => dispatch({ type: 'SEARCH_USERS_FAILURE' })); }, 300); }; // Проблема: таймер на рівні модуля зберігається між тестами і hot reloads
javascript
// Saga - вбудований ефект debounce, без зовнішнього стану import { debounce, call, put } from 'redux-saga/effects'; function* searchUsersSaga({ payload: query }) { try { const users = yield call(api.searchUsers, query); yield put({ type: 'SEARCH_USERS_SUCCESS', payload: users }); } catch { yield put({ type: 'SEARCH_USERS_FAILURE' }); } } export function* watchSearch() { yield debounce(300, 'SEARCH_USERS', searchUsersSaga); // чекає 300мс після останньої дії SEARCH_USERS перед викликом searchUsersSaga }

Версія на Saga коротша, без стану на рівні модуля і коректно скидається між тестами.

Просунутий: fallback API з race condition

javascript
// Thunk - Promise.race з ручною перевіркою, той що програв продовжує виконуватись const searchWithFallback = (query) => (dispatch) => Promise.race([ api.searchPrimary(query).catch(() => null), api.searchFallback(query).catch(() => null) ]) .then(result => { if (!result) return dispatch({ type: 'SEARCH_FAILURE' }); dispatch({ type: 'SEARCH_SUCCESS', payload: result }); }); // Запит що програв продовжує виконуватись у фоні - скасування відсутнє
javascript
// Saga - ефект race автоматично скасовує того хто програв import { race, call, put } from 'redux-saga/effects'; function* searchWithFallback({ payload: query }) { const { primary, fallback } = yield race({ primary: call(api.searchPrimary, query), fallback: call(api.searchFallback, query) }); if (primary) yield put({ type: 'SEARCH_SUCCESS', payload: primary }); else if (fallback) yield put({ type: 'SEARCH_SUCCESS', payload: fallback }); else yield put({ type: 'SEARCH_FAILURE' }); } // Коли primary вирішується першим, Saga автоматично скасовує fallback виклик

Коли primary вирішується першим, Saga скасовує fallback. Версія на Thunk дозволяє обом запитам завершитись незалежно від того хто переміг. На мобільних пристроях або при повільному з'єднанні цей зайвий запит щоразу витрачає трафік.

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

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

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

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