Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Redux Thunk vs Redux Saga - яка різниця?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Redux Thunk vs Redux Saga** - Thunk дозволяє action creator-ам повертати функції, що викликають `dispatch` після async-роботи; Saga yield-ить об'єкти-ефекти, які middleware виконує. ```javascript const loadThunk = (id) => (dispatch) => api.get(id).then(data => dispatch({ type: 'SUCCESS', payload: data })); function* loadSaga(action) { const data = yield call(api.get, action.payload); yield put({ type: 'SUCCESS', payload: data }); } ``` **Ключове:** Thunk для простих API-запитів; Saga для скасувань, race conditions і debounce.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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 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` для залежної логіки** ```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 дозволяє обом запитам завершитись незалежно від того хто переміг. На мобільних пристроях або при повільному з'єднанні цей зайвий запит щоразу витрачає трафік.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.