Skip to main content

Redux middleware

Redux middleware є функцією, яка знаходиться між dispatch і редюсером та перехоплює дії до того, як вони досягають сховища.

Теорія

TL;DR

  • Middleware схожий на сортувальний центр пошти: дії надходять, проходять обробку або перенаправлення, потім ідуть до редюсера
  • Сигнатура: store => next => action => result (три вкладені функції)
  • Виклик next(action) передає дію далі; пропустити цей виклик означає заблокувати дію повністю
  • Middleware підходить для побічних ефектів (async-запити, логування, аналітика); редюсер - для змін стану
  • Порядок важливий: applyMiddleware(thunkMiddleware, loggerMiddleware) запускає thunk першим

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

javascript
// Сигнатура: store => next => action => result const loggerMiddleware = store => next => action => { console.log('dispatching:', action); const result = next(action); // передаємо далі console.log('new state:', store.getState()); return result; }; const store = createStore(rootReducer, applyMiddleware(loggerMiddleware)); // Output: "dispatching: {type: 'ADD_TODO', payload: 'Learn Redux'}" // Output: "new state: {todos: [{id: 1, text: 'Learn Redux'}]}"

next(action) передає управління наступному middleware в ланцюжку. Після повернення можна зчитати оновлений стан. Це весь патерн.

Як працює ланцюжок middleware

Коли викликається dispatch(action), Redux не відправляє дію одразу до редюсера. Він передає її через кожен middleware зліва направо. Кожен middleware є функцією вищого порядку: перший виклик отримує store, другий отримує next (посилання на наступний middleware або фінальний dispatch, якщо він останній), третій отримує саму action.

Якщо middleware викликає next(action), дія рухається далі. Якщо ні - зупиняється там і ніколи не досягає редюсера. Redux будує цей ланцюжок один раз під час createStore(), загортаючи оригінальний dispatch через applyMiddleware().

Каррінг тут не випадковий. applyMiddleware частково застосовує store до кожного middleware під час ініціалізації, потім об'єднує результати в ланцюг. Логіка обробки дій виконується тільки в момент dispatch, не під час створення сховища.

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

Побічні ефекти належать в middleware, не в редюсери. Редюсери мають бути чистими функціями.

  • Async-операції (API-запити, таймери): Redux Thunk або Redux Saga
  • Логування і налагодження: фіксація кожної дії і зміни стану в dev-збірках
  • Аналітика: перехоплення конкретних типів дій і відправка подій до сервісу трекінгу
  • Обробка помилок: трансформація помилок до того, як вони потраплять до редюсера
  • Збагачення дій: додавання мітки часу або ID користувача до кожної вихідної дії
  • Скасування: блокування дій на основі поточного стану або умов

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

Подвійний виклик next()

javascript
// НЕПРАВИЛЬНО - дія потрапляє до редюсера двічі const badMiddleware = store => next => action => { next(action); next(action); }; // ПРАВИЛЬНО - викликаємо next() рівно один раз const goodMiddleware = store => next => action => { console.log('before:', action); const result = next(action); console.log('after:', store.getState()); return result; };

Відсутній return

javascript
// НЕПРАВИЛЬНО - ламає ланцюжок для підписників const badMiddleware = store => next => action => { next(action); // немає return }; // ПРАВИЛЬНО const goodMiddleware = store => next => action => { return next(action); };

Мутація об'єкту дії

javascript
// НЕПРАВИЛЬНО - змінює оригінальну дію const badMiddleware = store => next => action => { action.timestamp = Date.now(); return next(action); }; // ПРАВИЛЬНО - створюємо новий об'єкт через spread const goodMiddleware = store => next => action => { return next({ ...action, timestamp: Date.now(), userId: store.getState().auth.userId }); };

Неправильний порядок middleware

javascript
// НЕПРАВИЛЬНО - logger бачить функції до того, як thunk їх обробив const store = createStore( reducer, applyMiddleware(loggerMiddleware, thunkMiddleware) ); // ПРАВИЛЬНО - thunk першим перетворює функції на plain objects const store = createStore( reducer, applyMiddleware(thunkMiddleware, loggerMiddleware) );

Async-дії без Thunk

javascript
// НЕПРАВИЛЬНО - dispatch очікує plain object dispatch(async () => { const data = await fetch('/api/data'); // ця функція ніколи не виконається }); // ПРАВИЛЬНО - додаємо thunk middleware const store = createStore(reducer, applyMiddleware(thunk)); dispatch((dispatch) => { fetch('/api/data').then(data => dispatch({ type: 'SET_DATA', payload: data })); });

Де зустрічається в реальних проектах

  • Redux Thunk (найпоширеніший): dispatch-функції з async API-запитами
  • Redux Saga: складні потоки зі скасуванням і race conditions, використовується у великих проектах на кшталт фронтенду Uber
  • Redux Observable: на базі RxJS, для реактивних потоків
  • Redux Logger: логує кожну дію і зміну стану в dev-збірках
  • Redux Persist: перехоплює всі дії і автоматично синхронізує стан з localStorage
  • Кастомний middleware для аналітики: перехоплює конкретні типи дій і відправляє їх до сервісу трекінгу

Питання на співбесіді

Q: Чому middleware є каррінг-функцією (store => next => action => result), а не звичайною?
A: Каррінг дозволяє Redux частково застосувати аргументи під час налаштування. applyMiddleware один раз викликає зовнішню функцію зі store, потім об'єднує результати в ланцюг. Логіка обробки дій виконується тільки в момент dispatch.

Q: Що станеться, якщо middleware не викличе next()?
A: Дія припиняє поширення і ніколи не досягає редюсера або наступних middleware. Підписники все одно отримують сповіщення про dispatch-виклик, що може призводити до неочікуваної поведінки, якщо стан фактично не змінився.

Q: Чи може middleware читати поточний стан?
A: Так. store.getState() доступний всередині middleware. Виклик до next(action) повертає стан до обробки дії, виклик після - вже оновлений стан.

Q: В чому різниця між Redux Thunk і Redux Saga?
A: Thunk простіший: дозволяє dispatch-ити функції замість plain objects. Saga використовує генератори і підтримує складні сценарії на кшталт скасування і race conditions. Thunk підходить для звичайних async-запитів; Saga потрібна, коли треба координувати кілька запитів або скасовувати ті, що вже в процесі.

Q: (Рівень senior) Як запобігти race conditions, якщо одна async-дія відправляється кілька разів підряд?
A: Відстежувати запити в процесі виконання за типом дії всередині middleware. Якщо такий тип вже є - ігнорувати або ставити в чергу новий dispatch. Redux Saga вирішує це через takeLatest(), який автоматично скасовує попередній запит при надходженні нового.

Приклади

Базовий: Logger middleware

javascript
const loggerMiddleware = store => next => action => { console.log('dispatching:', action.type); const result = next(action); console.log('new state:', store.getState()); return result; }; const store = createStore(rootReducer, applyMiddleware(loggerMiddleware)); store.dispatch({ type: 'ADD_TODO', payload: 'Learn Redux' }); // dispatching: ADD_TODO // new state: { todos: [{ id: 1, text: 'Learn Redux' }] }

Простий у написанні, корисний під час розробки. Додавай після thunk в applyMiddleware, щоб бачити вже оброблені дії.

Середній: Async middleware (патерн Thunk)

javascript
const thunkMiddleware = store => next => action => { // якщо дія є функцією, викликаємо її з dispatch і getState if (typeof action === 'function') { return action(store.dispatch, store.getState); } return next(action); }; const fetchUser = (userId) => async (dispatch, getState) => { dispatch({ type: 'FETCH_USER_START' }); try { const response = await fetch(`/api/users/${userId}`); const user = await response.json(); dispatch({ type: 'FETCH_USER_SUCCESS', payload: user }); } catch (error) { dispatch({ type: 'FETCH_USER_ERROR', payload: error.message }); } }; // В компоненті: store.dispatch(fetchUser(123)); // одразу dispatch-ить FETCH_USER_START, // потім FETCH_USER_SUCCESS або FETCH_USER_ERROR коли запит завершиться

Thunk перевіряє чи дія є функцією. Якщо так - викликає її з dispatch і getState. Якщо ні - передає далі звичайним чином. Це вся реалізація.

Просунутий: Middleware для дедублікації

javascript
const dedupeMiddleware = store => { let lastAction = null; let lastTime = 0; return next => action => { const now = Date.now(); const isDuplicate = lastAction?.type === action.type && lastAction?.payload === action.payload && now - lastTime < 500; // одна й та сама дія за 500ms if (isDuplicate) { console.warn('Duplicate action blocked:', action.type); return; // пропускаємо next() повністю } lastAction = action; lastTime = now; return next(action); }; }; // dispatch({ type: 'SUBMIT_FORM', payload: formData }) двічі за 500ms // Другий dispatch блокується. До редюсера доходить тільки одна дія.

Зверни увагу на замикання (closure) над lastAction і lastTime. Middleware зберігає цей стан між dispatch-викликами, бо зовнішня функція виконується один раз під час ініціалізації сховища. Цей патерн я особисто застосовував у продакшені для захисту від подвійного сабміту форм на повільних з'єднаннях: кнопка спрацьовує двічі, але до сервера йде тільки один запит.

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

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

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

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