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:
// 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 бачить функцію і викликає її// 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 Thunk | Redux Saga |
|---|---|---|
| Механізм | Диспатчена функція виконує async-код | Генератор yield-ить ефекти, middleware виконує |
| Async | async/await або promises всередині thunk | yield 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 для залежної логіки
// Неправильно - запит без перевірки кешу
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()
// Неправильно - синхронний виклик, 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-и (нескінченний цикл)
// Неправильно - якщо handler диспатчить ту саму дію, вона знову запускається
function* watchFetch() {
yield takeEvery('FETCH_DATA', handleFetch);
}
// Правильно - takeLatest скасовує попереднє завдання при кожній новій дії
function* watchFetch() {
yield takeLatest('FETCH_DATA', handleFetch);
}4. Thunk: відсутність cleanup після unmount компонента
// Неправильно - диспатч у 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.
Приклади
Базовий: запит користувача за дією
// 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 перехоплює і викликає внутрішню функцію// 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
// 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// 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
// 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 });
});
// Запит що програв продовжує виконуватись у фоні - скасування відсутнє// 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 дозволяє обом запитам завершитись незалежно від того хто переміг. На мобільних пристроях або при повільному з'єднанні цей зайвий запит щоразу витрачає трафік.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.