Skip to main content

Redux middleware

Redux middleware is a function that sits between dispatch and the reducer, intercepting actions before they reach the store.

Theory

TL;DR

  • Middleware is like a mail sorting facility: actions arrive, get processed or redirected, then continue to the reducer
  • Signature: store => next => action => result (three curried functions nested inside each other)
  • Call next(action) to pass the action forward; skip that call entirely to block the action
  • Use middleware for side effects (async calls, logging, analytics); use reducers for state changes
  • Order matters: applyMiddleware(thunkMiddleware, loggerMiddleware) runs thunk first

Quick Example

javascript
// Middleware signature: store => next => action => result const loggerMiddleware = store => next => action => { console.log('dispatching:', action); const result = next(action); // pass to next middleware or reducer 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) hands control to the next middleware in the chain. After it returns, you can read the updated state. That is the whole pattern.

How the Middleware Chain Works

When you call dispatch(action), Redux does not send it straight to the reducer. It passes it through each middleware from left to right. Each middleware is a higher-order function: the first call receives store, the second receives next (a reference to the next middleware, or the final dispatch if it is last in line), and the third receives the actual action.

If a middleware calls next(action), the action moves forward. If it skips that call, the action stops there and never reaches the reducer. Redux builds this chain once during createStore() by wrapping the original dispatch with applyMiddleware().

The curried structure exists for a specific reason. applyMiddleware partially applies store to each middleware during setup, then chains the results together. The action-handling logic only runs at dispatch time, not during store initialization.

When to Use Middleware

Side effects belong in middleware, not in reducers. Reducers must be pure functions.

  • Async operations (API calls, timers): use Redux Thunk or Redux Saga
  • Logging and debugging: log every action and state change in development builds
  • Analytics: intercept specific action types and send events to a tracking service
  • Error handling: catch and transform errors before they hit the reducer
  • Action transformation: add a timestamp or user ID to every outgoing action
  • Cancellation: block actions from reaching the reducer based on current state or conditions

Common Mistakes

Calling next() twice

javascript
// WRONG - action reaches reducer twice const badMiddleware = store => next => action => { next(action); next(action); }; // RIGHT - call next() exactly once const goodMiddleware = store => next => action => { console.log('before:', action); const result = next(action); console.log('after:', store.getState()); return result; };

Forgetting to return the result

javascript
// WRONG - breaks the chain for subscribers const badMiddleware = store => next => action => { next(action); // missing return }; // RIGHT const goodMiddleware = store => next => action => { return next(action); };

Mutating the action object

javascript
// WRONG - mutates the original action const badMiddleware = store => next => action => { action.timestamp = Date.now(); return next(action); }; // RIGHT - spread into a new object const goodMiddleware = store => next => action => { return next({ ...action, timestamp: Date.now(), userId: store.getState().auth.userId }); };

Getting middleware order wrong

javascript
// WRONG - logger runs before thunk resolves async actions const store = createStore( reducer, applyMiddleware(loggerMiddleware, thunkMiddleware) ); // RIGHT - thunk converts function actions first, then logger sees plain objects const store = createStore( reducer, applyMiddleware(thunkMiddleware, loggerMiddleware) );

Dispatching async functions without Thunk

javascript
// WRONG - dispatch expects a plain object dispatch(async () => { const data = await fetch('/api/data'); // this function never executes }); // RIGHT - add thunk middleware first const store = createStore(reducer, applyMiddleware(thunk)); dispatch((dispatch) => { fetch('/api/data').then(data => dispatch({ type: 'SET_DATA', payload: data })); });

Real-World Usage

  • Redux Thunk (most common): dispatch functions that contain async API calls
  • Redux Saga: complex flows with cancellation and race conditions, used in large apps like Uber's frontend
  • Redux Observable: RxJS-based, for reactive stream operations
  • Redux Logger: logs every action and state diff in development builds
  • Redux Persist: intercepts all actions to sync state with localStorage automatically
  • Custom analytics middleware: catches specific action types and sends them to a tracking service

Follow-Up Questions

Q: Why is middleware a curried function (store => next => action => result) instead of a regular function?
A: Currying lets Redux partially apply arguments during setup. applyMiddleware calls the outer function with store once, then chains the resulting functions together. The action-handling logic only runs at dispatch time, not during store creation.

Q: What happens if a middleware does not call next()?
A: The action stops propagating. It never reaches the reducer or any middleware after it. Subscribers still get notified of the dispatch call, which can cause unexpected behavior if state did not actually change.

Q: Can middleware read the current state?
A: Yes. store.getState() is available inside the middleware. Call it before next(action) to see the state before processing, or after to see the updated state.

Q: What is the difference between Redux Thunk and Redux Saga?
A: Thunk is simpler: it lets you dispatch functions instead of plain objects. Saga uses generator functions and handles complex flows like cancellation and race conditions. Use Thunk for straightforward async calls; use Saga when you need to coordinate multiple requests or cancel in-flight ones.

Q: (Senior-level) How would you prevent race conditions when the same async action is dispatched multiple times rapidly?
A: Track pending requests by action type inside the middleware. If the same type is already in-flight, ignore or queue the new dispatch. Redux Saga handles this with takeLatest(), which automatically cancels the previous request when a new one arrives.

Examples

Basic: 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' }] }

Simple to write, useful in development. Add it after thunk in applyMiddleware so it sees already-resolved actions.

Intermediate: Async Middleware (Thunk Pattern)

javascript
const thunkMiddleware = store => next => action => { // if the action is a function, call it with dispatch and 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 }); } }; // In a component: store.dispatch(fetchUser(123)); // dispatches FETCH_USER_START immediately, // then FETCH_USER_SUCCESS or FETCH_USER_ERROR when the request settles

Thunk checks if the action is a function. If yes, it calls that function with dispatch and getState. If no, it passes the action through as normal. That is the entire implementation.

Advanced: Deduplication 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; // same action within 500ms if (isDuplicate) { console.warn('Duplicate action blocked:', action.type); return; // skip next() entirely } lastAction = action; lastTime = now; return next(action); }; }; // dispatch({ type: 'SUBMIT_FORM', payload: formData }) twice within 500ms // Second dispatch is blocked. Only one action reaches the reducer.

Note the closure over lastAction and lastTime. The middleware keeps that state across dispatches because the outer function runs once during store setup. This pattern came up in production when handling double-submit bugs on slow network connections: the form button fires twice, but only one request goes through.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?