Уявіть, що ви створюєте інтернет-магазин. Користувач натискає кнопку "Додати в кошик" — і товар миттєво з'являється в списку. Це синхронна операція: натиснули → змінився стан → оновився UI. Але що, якщо користувач натискає "Купити зараз"?
Тепер вам потрібно:
Це вже асинхронна операція. І ось тут класичний Redux натикається на стіну: actions та reducers не вміють чекати.
Досі наші actions були синхронними об'єктами: { type: 'ADD_TODO', payload: 'Buy milk' }. Але як описати процес, розтягнутий у часі? Тут на сцену виходить Middleware.
Перед вивченням цього розділу переконайтеся, що розумієте:
На зорі Redux (2015 рік) розробники швидко зіткнулися з проблемою: як робити AJAX-запити? Перші спроби виглядали так:
// ❌ АНТИПАТЕРН: Fetch у компоненті, потім dispatch
function TodoList() {
const dispatch = useDispatch()
useEffect(() => {
fetch('/api/todos')
.then((res) => res.json())
.then((data) => dispatch({ type: 'TODOS_LOADED', payload: data }))
}, [])
}
Проблеми цього підходу:
Тоді з'явилася ідея: а що, якщо винести цю логіку в саме Redux? Так народилася концепція middleware.
Згадаємо золоте правило Redux:
Date.now(), тощоЧому це так важливо?
// ❌ ПОГАНО: Редюсер з побічним ефектом
function userReducer(state, action) {
if (action.type === 'FETCH_USER') {
// Це НЕ ПРАЦЮВАТИМЕ!
fetch('/api/user')
.then(res => res.json())
.then(data => ??? ); // Як тут оновити стан?
return state; // Повернули старий стан, але fetch ще летить!
}
}
Проблеми:
fetch — це Promise, яка резолвиться пізнішеdispatch, тому не може сам відправити новий action з данимиSide Effect — це будь-яка операція, яка виходить за межі функції та взаємодіє із зовнішнім світом.
function add(a, b) {
return a + b // Завжди однаковий результат для одних аргументів
}
let counter = 0
function addAndLog(a, b) {
counter++ // Мутація зовнішньої змінної
console.log('Adding...') // Логування (I/O)
fetch('/api/log', { body: a + b }) // Мережевий запит
return a + b + Math.random() // Випадковість
}
Приклади побічних ефектів у веб-додатках:
Redux вирішує цю проблему елегантно: винести побічні ефекти за межі редюсерів, але залишити їх усередині екосистеми Redux.
Middleware — це прошарок між dispatch(action) та моментом, коли action досягає редюсера. Тут можна:
Уявіть Redux Store як охороняєму будівлю:
Охоронець (middleware) може:
Розберемо детально, що відбувається при dispatch(action) у Redux зі встановленим middleware:
Ключові моменти:
applyMiddleware)next(action)dispatch повторно, створюючи нові actionsapplyMiddleware — це store enhancer (покращувач стору). Він обгортає оригінальний dispatch у ланцюжок функцій.
Спрощена реалізація:
// Так виглядає applyMiddleware всередині (спрощено)
function applyMiddleware(...middlewares) {
return (createStore) => (reducer, preloadedState) => {
const store = createStore(reducer, preloadedState)
let dispatch = store.dispatch
// Даємо кожному middleware доступ до getState та dispatch
const middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action), // Важливо: closure!
}
// Перетворюємо кожен middleware з формату:
// storeAPI => next => action => result
// У формат: next => action => result
const chain = middlewares.map((middleware) => middleware(middlewareAPI))
// Складаємо їх справа наліво: MW1(MW2(MW3(originalDispatch)))
dispatch = compose(...chain)(store.dispatch)
return { ...store, dispatch }
}
}
Що тут відбувається:
dispatchgetState та dispatchcomposedispatch замінюється на обгорнуту версіюСигнатура middleware виглядає страшно для новачків:
const middleware = (storeAPI) => (next) => (action) => {
// ...
}
Чому ТРИ рівні функцій? Розберемо покроково:
Мета: Отримати доступ до getState та dispatch
const loggerMiddleware = (storeAPI) => {
// Тут є доступ до storeAPI.getState() та storeAPI.dispatch()
// Цей рівень викликається ОДИН РАЗ при ініціалізації store
return (next) => {
// ...
}
}
Мета: Отримати посилання на наступний middleware (або редюсер)
const loggerMiddleware = (storeAPI) => (next) => {
// next — це або наступний middleware, або оригінальний dispatch
// Цей рівень викликається при setup middleware chain
return (action) => {
// ...
}
}
Мета: Обробити конкретний action
const loggerMiddleware = (storeAPI) => (next) => (action) => {
// Це викликається КОЖНОГО РАЗУ при dispatch(action)
console.log('Dispatching:', action)
const result = next(action) // Передаємо далі
console.log('Next state:', storeAPI.getState())
return result
}
Повна картина:
// Використання currying для гнучкості
const createLoggerMiddleware = (options) => (storeAPI) => (next) => (action) => {
if (options.enabled) {
console.log('[Logger]', action.type)
}
return next(action)
}
// Тепер можна налаштовувати middleware
const logger = createLoggerMiddleware({ enabled: true })
const store = createStore(reducer, applyMiddleware(logger))
Redux Thunk — це всього 10 рядків коду! Ось повна реалізація:
// Офіційна реалізація redux-thunk (спрощено)
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) =>
(next) =>
(action) => {
// Якщо action — це функція, викликаємо її
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument)
}
// Інакше просто передаємо далі
return next(action)
}
}
const thunk = createThunkMiddleware()
export default thunk
Розбір по рядках:
typeof action === 'function': Перевіряємо, чи action — це функція (тобто thunk)action(dispatch, getState, extraArgument): Якщо так — викликаємо її, передаючи:
dispatch — щоб thunk міг диспатчити інші actionsgetState — щоб thunk міг читати поточний станextraArgument — додатковий аргумент (наприклад, API client)return next(action): Якщо це звичайний об'єкт — пропускаємо даліОсь і вся магія! Thunk middleware просто розпізнає функції та виконує їх, даючи їм доступ до dispatch.
Thunk (від англ. "думка про щось") — це функція, яка повертає іншу функцію.
// Звичайна функція
function getValue() {
return 42
}
// Thunk функція
function getValueLater() {
return function () {
// Повертаємо функцію!
return 42
}
}
const thunk = getValueLater() // Отримали функцію
const value = thunk() // Тепер викликали і отримали 42
Навіщо це потрібно? Відкладання обчислень! Функція, загорнута в іншу функцію, може виконатися пізніше.
// Повертає простий об'єкт
function addTodo(text) {
return {
type: 'ADD_TODO',
payload: text,
}
}
// Використання
dispatch(addTodo('Buy milk'))
// Відразу потрапить до редюсера
// Повертає ФУНКЦІЮ!
function fetchTodos() {
return async (dispatch, getState) => {
// Тут може бути будь-яка асинхронна логіка
const response = await fetch('/api/todos')
const data = await response.json()
dispatch({ type: 'TODOS_LOADED', payload: data })
}
}
// Використання (ТОЧНО ТАК САМО!)
dispatch(fetchTodos())
// Thunk middleware перехопить та виконає функцію
Ключова різниця:
{ type, payload } → відразу до reducer(dispatch, getState) => { ... } → виконується middlewarenpm install redux-thunk
Або з yarn:
yarn add redux-thunk
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import rootReducer from './reducers'
const store = createStore(
rootReducer,
applyMiddleware(thunk), // Додаємо middleware
)
export default store
Пояснення рядків:
applyMiddleware(thunk): Створює store enhancer, що обгортає dispatchdispatch може приймати як об'єкти, так і функції!// actions/todoActions.js
// 1. Визначаємо прості action creators
const fetchTodosStart = () => ({ type: 'FETCH_TODOS_START' })
const fetchTodosSuccess = (todos) => ({
type: 'FETCH_TODOS_SUCCESS',
payload: todos,
})
const fetchTodosError = (error) => ({
type: 'FETCH_TODOS_ERROR',
payload: error,
})
// 2. Визначаємо thunk action creator
export const fetchTodos = () => {
// Повертаємо функцію (thunk)!
return async (dispatch, getState) => {
// dispatch — функція для відправки actions
// getState — функція для читання поточного стану
// Крок 1: Повідомляємо, що почали завантаження
dispatch(fetchTodosStart())
try {
// Крок 2: Робимо асинхронний запит
const response = await fetch('https://jsonplaceholder.typicode.com/todos')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
// Крок 3: Успіх! Відправляємо дані
dispatch(fetchTodosSuccess(data))
} catch (error) {
// Крок 4: Помилка! Відправляємо error
dispatch(fetchTodosError(error.message))
}
}
}
Пояснення ключових моментів:
Start, Success, Error) — стандартний патерн для async операційasync (dispatch, getState) => { ... } — це thunk функція. Redux Thunk автоматично передасть їй ці аргументиdispatch(fetchTodosStart()) — ми можемо диспатчити інші actions всередині thunkawait fetch(...) — можна використовувати async/await, бо це звичайна JS функція!// Приклад: Завантажуємо todos тільки якщо їх ще немає
export const fetchTodosIfNeeded = () => {
return (dispatch, getState) => {
const state = getState()
// Перевіряємо, чи вже завантажені
if (state.todos.items.length > 0) {
console.log('Todos вже є, не завантажуємо')
return Promise.resolve() // Повертаємо resolved Promise
}
// Якщо немає — завантажуємо
return dispatch(fetchTodos())
}
}
dispatch(fetchTodos()).then(() => {
console.log('Todos завантажені!')
})
// reducers/todosReducer.js
const initialState = {
items: [],
loading: false,
error: null,
}
function todosReducer(state = initialState, action) {
switch (action.type) {
case 'FETCH_TODOS_START':
return {
...state,
loading: true,
error: null, // Скидаємо попередню помилку
}
case 'FETCH_TODOS_SUCCESS':
return {
...state,
loading: false,
items: action.payload,
error: null,
}
case 'FETCH_TODOS_ERROR':
return {
...state,
loading: false,
error: action.payload,
}
default:
return state
}
}
export default todosReducer
Пояснення Loading States State Machine:
// components/TodoList.jsx
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { fetchTodos } from '../actions/todoActions'
function TodoList() {
const dispatch = useDispatch()
// Підписуємося на стан
const { items, loading, error } = useSelector((state) => state.todos)
useEffect(() => {
// Диспатчимо thunk так само, як звичайний action!
dispatch(fetchTodos())
}, [dispatch])
if (loading) {
return <div className="spinner">Loading...</div>
}
if (error) {
return <div className="error">Error: {error}</div>
}
return (
<ul>
{items.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
export default TodoList
Зверніть увагу: Компонент не знає, що fetchTodos() — це thunk! Для нього це просто action.
.then().catch() може спіймати помилки не з мережевого запиту!// ❌ ПОГАНО
export const fetchTodos = () => {
return (dispatch) => {
dispatch(fetchStart())
fetch('/api/todos')
.then((res) => res.json())
.then((data) => {
dispatch(fetchSuccess(data)) // А якщо тут вилетить помилка?
processData(data) // Наприклад, тут!
})
.catch((error) => {
// Сюди потраплять помилки І з fetch, І з processData!
dispatch(fetchError(error.message))
})
}
}
.catch() спіймає помилки з processData, хоча це не помилка мережі!// ✅ ПРАВИЛЬНО
export const fetchTodos = () => {
return (dispatch) => {
dispatch(fetchStart())
fetch('/api/todos')
.then(
// Success handler
(response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json()
},
// Error handler (тільки мережеві помилки!)
(networkError) => {
dispatch(fetchError(networkError.message))
},
)
.then((data) => {
if (data) {
// data буде undefined у випадку помилки
dispatch(fetchSuccess(data))
processData(data) // Помилка тут НЕ спіймається як fetch error
}
})
}
}
// ✅ НАЙКРАЩЕ: Явна обробка помилок
export const fetchTodos = () => {
return async (dispatch) => {
dispatch(fetchStart())
try {
const response = await fetch('/api/todos')
// Перевіряємо HTTP статус
if (!response.ok) {
throw new Error(`Server error: ${response.status}`)
}
const data = await response.json()
// Валідація даних перед dispatch
if (!Array.isArray(data)) {
throw new Error('Invalid data format')
}
dispatch(fetchSuccess(data))
// Побічні ефекти ПІСЛЯ успішного dispatch
processData(data)
} catch (error) {
// Тут спіймаємо:
// 1. Мережеві помилки (no internet, timeout)
// 2. HTTP помилки (404, 500)
// 3. JSON parsing помилки
// 4. Помилки валідації
dispatch(fetchError(error.message))
// Опціонально: логування в Sentry
console.error('Fetch todos failed:', error)
}
}
}
Не завантажувати дані, якщо вони вже є:
export const fetchUserIfNeeded = (userId) => {
return (dispatch, getState) => {
const { users } = getState()
// Перевіряємо cache
const existingUser = users.byId[userId]
if (existingUser && !existingUser.isStale) {
console.log('User вже є в кеші')
return Promise.resolve(existingUser)
}
// Завантажуємо тільки якщо потрібно
return dispatch(fetchUser(userId))
}
}
Виконати кілька запитів послідовно:
export const fetchUserWithPosts = (userId) => {
return async (dispatch) => {
// 1. Завантажуємо користувача
await dispatch(fetchUser(userId))
// 2. Потім його пости
await dispatch(fetchUserPosts(userId))
// 3. І коментарі до постів
await dispatch(fetchPostComments(userId))
}
}
Паралельне завантаження:
export const fetchDashboardData = () => {
return async (dispatch) => {
dispatch({ type: 'DASHBOARD_LOAD_START' })
try {
// Завантажуємо все паралельно
await Promise.all([dispatch(fetchUser()), dispatch(fetchPosts()), dispatch(fetchNotifications())])
dispatch({ type: 'DASHBOARD_LOAD_SUCCESS' })
} catch (error) {
dispatch({ type: 'DASHBOARD_LOAD_ERROR', payload: error.message })
}
}
}
Використання AbortController:
// Зберігаємо AbortController у зовнішній змінній
let abortController = null
export const fetchTodos = () => {
return async (dispatch) => {
// Скасовуємо попередній запит, якщо він ще йде
if (abortController) {
abortController.abort()
}
// Створюємо новий controller
abortController = new AbortController()
const { signal } = abortController
dispatch(fetchStart())
try {
const response = await fetch('/api/todos', { signal })
const data = await response.json()
dispatch(fetchSuccess(data))
} catch (error) {
if (error.name === 'AbortError') {
console.log('Fetch cancelled')
// Не диспатчимо помилку для cancelled запитів
} else {
dispatch(fetchError(error.message))
}
}
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export const fetchTodosWithRetry = (maxRetries = 3) => {
return async (dispatch) => {
dispatch(fetchStart())
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch('/api/todos')
const data = await response.json()
dispatch(fetchSuccess(data))
return // Успіх! Виходимо
} catch (error) {
if (attempt === maxRetries) {
// Останя спроба провалилась
dispatch(fetchError(`Failed after ${maxRetries} attempts`))
} else {
// Чекаємо перед наступною спробою (exponential backoff)
console.log(`Attempt ${attempt} failed, retrying...`)
await delay(1000 * Math.pow(2, attempt)) // 2s, 4s, 8s...
}
}
}
}
}
Оновлюємо UI до відповіді сервера:
export const toggleTodo = (todoId) => {
return async (dispatch, getState) => {
const todo = getState().todos.byId[todoId]
// 1. Миттєво оновлюємо UI (optimistic)
dispatch({
type: 'TODO_TOGGLED_OPTIMISTIC',
payload: { id: todoId, completed: !todo.completed },
})
try {
// 2. Відправляємо на сервер
const response = await fetch(`/api/todos/${todoId}`, {
method: 'PATCH',
body: JSON.stringify({ completed: !todo.completed }),
})
const updatedTodo = await response.json()
// 3. Підтверджуємо оновлення даними з сервера
dispatch({
type: 'TODO_TOGGLED_SUCCESS',
payload: updatedTodo,
})
} catch (error) {
// 4. Якщо помилка — відкочуємо зміни (rollback)
dispatch({
type: 'TODO_TOGGLED_ROLLBACK',
payload: { id: todoId, completed: todo.completed },
})
dispatch(showError('Failed to update todo'))
}
}
}
Передати API client у thunks:
// store.js
import thunk from 'redux-thunk'
import api from './api' // Ваш API client
const store = createStore(rootReducer, applyMiddleware(thunk.withExtraArgument(api)))
// actions/todoActions.js
export const fetchTodos = () => {
// Третій аргумент — це extraArgument!
return async (dispatch, getState, api) => {
dispatch(fetchStart())
try {
const data = await api.todos.getAll() // Використовуємо api
dispatch(fetchSuccess(data))
} catch (error) {
dispatch(fetchError(error.message))
}
}
}
thunk.withExtraArgument({ api, analytics, logger })const logger = (store) => (next) => (action) => {
console.group(action.type)
console.log('Dispatching:', action)
const result = next(action)
console.log('Next state:', store.getState())
console.groupEnd()
return result
}
const crashReporter = (store) => (next) => (action) => {
try {
return next(action)
} catch (err) {
console.error('Caught an exception!', err)
// Відправити в Sentry
Sentry.captureException(err, {
extra: {
action,
state: store.getState(),
},
})
throw err // Re-throw, щоб не приховати
}
}
const analytics = (store) => (next) => (action) => {
// Відправляємо події в Google Analytics
if (action.type.startsWith('USER_')) {
window.gtag('event', action.type, {
...action.payload,
})
}
return next(action)
}
| Feature | Redux Thunk | Redux Saga | Redux Observable |
|---|---|---|---|
| Складність навчання | ⭐ Низька | ⭐⭐⭐ Висока | ⭐⭐⭐⭐ Дуже висока |
| Розмір бандлу | ~500 bytes | ~10 KB | ~50 KB (+ RxJS) |
| Cancellation | ❌ Вручну | ✅ Вбудована | ✅ Вбудована |
| Тестування | Легко (pure функції) | Складніше (генератори) | Складніше (observables) |
| Best for | 90% випадків | Складні async flows | Reactive patterns, WebSockets |
| Debouncing/Throttling | ❌ Вручну | ✅ debounce(), throttle() | ✅ debounceTime(), throttleTime() |
Причина: Ви забули підключити redux-thunk middleware!
// ❌ ПОГАНО: Немає middleware
const store = createStore(rootReducer)
// ✅ ДОБРЕ
const store = createStore(rootReducer, applyMiddleware(thunk))
Причина: Неправильна сигнатура thunk. Забули return:
// ❌ ПОГАНО
export const fetchTodos = () => {
;async (dispatch) => {
// Забули return!
// ...
}
}
// ✅ ДОБРЕ
export const fetchTodos = () => {
return async (dispatch) => {
// ...
}
}
Причина: Намагаєтесь передати функцію, Promise або клас в action payload:
// ❌ ПОГАНО
dispatch({
type: 'SET_CALLBACK',
payload: () => console.log('hi'), // Функції не serializable!
})
// ✅ ДОБРЕ: Зберігайте тільки дані
dispatch({
type: 'SET_CONFIG',
payload: { callbackName: 'onSuccess' },
})
import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
const composeEnhancers = (typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(thunk)))
Що дає DevTools:
export const fetchTodos = () => {
return async (dispatch, getState) => {
console.log('🚀 Starting fetch, current state:', getState())
dispatch(fetchStart())
console.log('📡 Fetch started, loading:', getState().todos.loading)
const data = await fetch('/api/todos').then((r) => r.json())
console.log('✅ Data received:', data)
dispatch(fetchSuccess(data))
console.log('💾 Data saved to store:', getState().todos.items)
}
}
dispatch(fetchStart())getState() у консоліnpm install redux-logger
import logger from 'redux-logger'
const store = createStore(
rootReducer,
applyMiddleware(thunk, logger), // logger останнім!
)
.then() для читабельностіЩоб зробити одну асинхронну операцію, нам довелося:
FETCH_START, FETCH_SUCCESS, FETCH_ERROR)switch редюсераloading та error statesДля 10 API endpoints = ~500 рядків коду! 😱
Саме це дратувало розробників роками. Redux був потужним, але занадто багатослівним.
У наступному розділі ми побачимо, як Redux Toolkit вирішує ці проблеми з допомогою createAsyncThunk та createSlice.
Підключення до React (React-Redux)
Сам по собі Redux нічого не знає про React. Щоб "пожружити" їх, нам потрібна офіційна бібліотека-прошарок: React Redux.
Проблеми класичного Redux
Ми тільки що витратили кілька годин (або розділів), вивчаючи класичний Redux. Ви могли помітити певний патерн: ми пишемо дуже багато коду, щоб зробити дуже прості речі.