Skip to main content

Custom Middleware

Middleware runs between dispatching an action and it reaching the reducer. It's useful for logging, API calls, and other side effects.

Basic Middleware​

const loggerMiddleware = (store) => (next) => (action) => {
console.log('Action:', action.type)
console.log('Before:', store.getState())

const result = next(action)

console.log('After:', store.getState())
return result
}

const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(loggerMiddleware),
})

How Middleware Works​

// This is the pattern:
const middleware = (store) => (next) => (action) => {
// Do something before action reaches reducer

const result = next(action) // Pass action to next middleware or reducer

// Do something after reducer runs

return result
}

Common Examples​

API Middleware​

const apiMiddleware = (store) => (next) => (action) => {
// Check if action has API call info
if (action.type === 'api/request') {
fetch(action.payload.url)
.then(response => response.json())
.then(data => {
store.dispatch({
type: action.payload.successType,
payload: data,
})
})
.catch(error => {
store.dispatch({
type: action.payload.errorType,
payload: error.message,
})
})
}

return next(action)
}

Analytics Middleware​

const analyticsMiddleware = (store) => (next) => (action) => {
// Track certain actions
if (action.type.includes('user/')) {
analytics.track('User Action', {
action: action.type,
timestamp: new Date().toISOString(),
})
}

return next(action)
}

Local Storage Middleware​

const localStorageMiddleware = (store) => (next) => (action) => {
const result = next(action)

// Save certain state to local storage
if (action.type.includes('auth/')) {
const state = store.getState()
localStorage.setItem('authToken', state.auth.token)
}

return result
}

Adding Multiple Middleware​

const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(loggerMiddleware)
.concat(analyticsMiddleware)
.concat(localStorageMiddleware),
})

Conditional Middleware​

const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) => {
const middleware = getDefaultMiddleware()

// Only add logger in development
if (process.env.NODE_ENV === 'development') {
middleware.concat(loggerMiddleware)
}

return middleware
},
})

Error Handling Middleware​

const errorMiddleware = (store) => (next) => (action) => {
try {
return next(action)
} catch (error) {
console.error('Redux Error:', error)

// Dispatch error action
store.dispatch({
type: 'error/actionFailed',
payload: {
action: action.type,
error: error.message,
},
})

// Don't break the app
return { type: 'error/handled' }
}
}

Async Middleware (Custom Thunk)​

const customThunkMiddleware = (store) => (next) => (action) => {
// If action is a function, call it with dispatch and getState
if (typeof action === 'function') {
return action(store.dispatch, store.getState)
}

// Otherwise, pass it along
return next(action)
}

// Usage
const fetchUser = (userId) => async (dispatch, getState) => {
dispatch({ type: 'user/loading' })

try {
const response = await fetch(`/api/users/${userId}`)
const user = await response.json()
dispatch({ type: 'user/loaded', payload: user })
} catch (error) {
dispatch({ type: 'user/error', payload: error.message })
}
}

Debug Middleware​

const debugMiddleware = (store) => (next) => (action) => {
const start = Date.now()
const result = next(action)
const end = Date.now()

console.log(`Action ${action.type} took ${end - start}ms`)

return result
}

Batching Middleware​

const batchMiddleware = (store) => (next) => {
let batch = []
let timeoutId = null

return (action) => {
if (action.type === 'batch/start') {
batch = []
return
}

if (action.type === 'batch/end') {
batch.forEach(batchedAction => next(batchedAction))
batch = []
return
}

// Add to batch if batching is active
if (batch.length >= 0 && action.meta?.batch) {
batch.push(action)

// Auto-flush batch after delay
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
batch.forEach(batchedAction => next(batchedAction))
batch = []
}, 10)

return
}

return next(action)
}
}

Testing Middleware​

// Test setup
const mockStore = {
getState: jest.fn(() => ({})),
dispatch: jest.fn(),
}

const next = jest.fn()

test('logger middleware logs actions', () => {
const action = { type: 'test/action' }

loggerMiddleware(mockStore)(next)(action)

expect(next).toHaveBeenCalledWith(action)
})

Key Points​

  1. Order matters - Middleware runs in the order you add it
  2. Call next() - Always call next(action) to continue the chain
  3. Return result - Return the result from next() or your own value
  4. Side effects - Perfect place for logging, analytics, API calls
  5. Don't mutate - Don't change the action object