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