Introduction to Middleware
Redux middleware provides a third-party extension point between dispatching an action and the moment it reaches the reducer. It allows you to add custom functionality to the Redux dispatch process.
🚦 The Highway Checkpoint Analogy
Think of middleware as checkpoints on a highway:
- Action: A car traveling on the highway
- Middleware: Checkpoints where cars can be inspected, modified, or redirected
- Reducer: The final destination
- Store: The city being updated
Just as checkpoints can inspect cargo, add documentation, or redirect traffic, middleware can inspect actions, add metadata, or trigger side effects.
The Middleware Pipeline
graph LR
A[dispatch action] --> B[Middleware 1]
B --> C[Middleware 2]
C --> D[Middleware 3]
D --> E[Reducer]
E --> F[New State]
F --> G[Store Notifies Subscribers]
style A fill:#f96
style B fill:#9cf
style C fill:#9cf
style D fill:#9cf
style E fill:#9f9
style F fill:#ff9
Understanding the Flow
// Without middleware
store.dispatch(action) -> reducer(state, action) -> newState
// With middleware
store.dispatch(action)
-> middleware1
-> middleware2
-> middleware3
-> reducer(state, action)
-> newState
// Each middleware can:
// 1. Pass the action to the next middleware
// 2. Modify the action before passing it
// 3. Stop the action from proceeding
// 4. Dispatch different actions
// 5. Execute side effects (API calls, logging, etc.)
Anatomy of Middleware
// Basic middleware structure
const myMiddleware = store => next => action => {
// Do something before the action reaches the reducer
console.log('Dispatching:', action);
// Pass the action to the next middleware or reducer
const result = next(action);
// Do something after the reducer has processed the action
console.log('Next state:', store.getState());
// Return the result (usually the action)
return result;
};
// Breaking down the curry function
function myMiddleware(store) {
return function wrapDispatch(next) {
return function handleAction(action) {
// Middleware logic here
return next(action);
};
};
}
// What each parameter represents:
// - store: { getState, dispatch }
// - next: The next middleware's dispatch function (or the reducer)
// - action: The action being dispatched
Common Middleware Patterns
1. Logging Middleware
const logger = store => next => action => {
console.group(action.type);
console.info('dispatching', action);
const result = next(action);
console.log('next state', store.getState());
console.groupEnd();
return result;
};
// More advanced logger with time tracking
const advancedLogger = store => next => action => {
const started = Date.now();
console.group(`action ${action.type}`);
console.log('prev state', store.getState());
console.log('action', action);
const result = next(action);
const elapsed = Date.now() - started;
console.log('next state', store.getState());
console.log(`elapsed time: ${elapsed}ms`);
console.groupEnd();
return result;
};
2. Crash Reporter Middleware
const crashReporter = store => next => action => {
try {
return next(action);
} catch (err) {
console.error('Caught an exception!', err);
// Send error to monitoring service
errorService.log({
error: err,
action,
state: store.getState()
});
// Re-throw the error so the app knows there was a problem
throw err;
}
};
// With error boundary integration
const enhancedCrashReporter = store => next => action => {
try {
return next(action);
} catch (err) {
// Log to service
errorService.log({
error: err,
action,
state: store.getState(),
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent
});
// Dispatch error action
store.dispatch({
type: 'ERROR_OCCURRED',
payload: {
message: err.message,
action: action.type
}
});
throw err;
}
};
3. API Middleware
const apiMiddleware = store => next => action => {
if (action.type !== 'API_REQUEST') {
return next(action);
}
const { url, method, data, onSuccess, onError } = action.payload;
// Dispatch request started action
next({ type: `${action.payload.feature}_REQUEST` });
// Make API call
fetch(url, {
method: method || 'GET',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
// Dispatch success action
next({
type: `${action.payload.feature}_SUCCESS`,
payload: data
});
// Call success callback if provided
if (onSuccess) onSuccess(data);
})
.catch(error => {
// Dispatch error action
next({
type: `${action.payload.feature}_ERROR`,
payload: error.message
});
// Call error callback if provided
if (onError) onError(error);
});
};
// Usage
dispatch({
type: 'API_REQUEST',
payload: {
url: '/api/users',
method: 'GET',
feature: 'USERS',
onSuccess: (data) => console.log('Users loaded:', data),
onError: (error) => console.error('Failed to load users:', error)
}
});
4. Analytics Middleware
const analytics = store => next => action => {
// Track specific actions
if (action.meta && action.meta.analytics) {
const { eventName, eventData } = action.meta.analytics;
// Send to analytics service
analyticsService.track(eventName, {
...eventData,
timestamp: Date.now(),
userId: store.getState().user?.id
});
}
return next(action);
};
// Usage with analytics metadata
dispatch({
type: 'PRODUCT_ADDED_TO_CART',
payload: { productId: '123', quantity: 1 },
meta: {
analytics: {
eventName: 'add_to_cart',
eventData: {
product_id: '123',
product_name: 'Redux T-Shirt',
price: 29.99,
currency: 'USD'
}
}
}
});
// Automatic analytics tracking
const autoAnalytics = store => next => action => {
const result = next(action);
// Automatically track certain action patterns
if (action.type.includes('_SUCCESS')) {
analyticsService.track('api_success', {
action: action.type,
timestamp: Date.now()
});
}
if (action.type.includes('_ERROR')) {
analyticsService.track('api_error', {
action: action.type,
error: action.payload,
timestamp: Date.now()
});
}
return result;
};
Composing Multiple Middleware
import { createStore, applyMiddleware } from 'redux';
// Multiple middleware
const store = createStore(
rootReducer,
applyMiddleware(
logger,
crashReporter,
apiMiddleware,
analytics
)
);
// How applyMiddleware works internally
function applyMiddleware(...middlewares) {
return (createStore) => (reducer, preloadedState) => {
const store = createStore(reducer, preloadedState);
// Create a chain of middleware
const chain = middlewares.map(middleware =>
middleware(store)
);
// Compose the chain
const dispatch = compose(...chain)(store.dispatch);
return {
...store,
dispatch
};
};
}
// Composition function
function compose(...funcs) {
if (funcs.length === 0) {
return arg => arg;
}
if (funcs.length === 1) {
return funcs[0];
}
return funcs.reduce((a, b) => (...args) => a(b(...args)));
}
// The result is:
// dispatch = logger(crashReporter(apiMiddleware(analytics(store.dispatch))))
Creating Custom Middleware
1. Authentication Middleware
const authMiddleware = store => next => action => {
// Check if action requires authentication
if (action.meta && action.meta.requiresAuth) {
const state = store.getState();
if (!state.auth.isAuthenticated) {
// Redirect to login
store.dispatch({
type: 'REDIRECT_TO_LOGIN',
payload: {
from: window.location.pathname,
reason: 'Authentication required'
}
});
// Stop the action
return;
}
}
// Add auth token to API requests
if (action.type === 'API_REQUEST') {
const token = store.getState().auth.token;
if (token) {
action.payload.headers = {
...action.payload.headers,
Authorization: `Bearer ${token}`
};
}
}
return next(action);
};
2. Validation Middleware
const validationMiddleware = store => next => action => {
// Validate action structure
if (!action.type) {
console.error('Action missing type property:', action);
throw new Error('Actions must have a type property');
}
// Validate specific action types
if (action.type === 'ADD_TODO' && !action.payload.text) {
return next({
type: 'ADD_TODO_ERROR',
payload: 'Todo text is required'
});
}
// Validate payload structure
if (action.type === 'UPDATE_USER' && action.payload) {
const { id, updates } = action.payload;
if (!id) {
throw new Error('User ID is required for UPDATE_USER action');
}
if (!updates || typeof updates !== 'object') {
throw new Error('Updates must be an object');
}
}
return next(action);
};
3. Throttling/Debouncing Middleware
const throttleMiddleware = () => {
const throttled = new Map();
return store => next => action => {
const time = action.meta?.throttle;
if (!time) {
return next(action);
}
const key = action.type;
const now = Date.now();
const lastCall = throttled.get(key);
if (lastCall && now - lastCall < time) {
// Skip this action
return;
}
throttled.set(key, now);
return next(action);
};
};
// Usage
dispatch({
type: 'SEARCH',
payload: searchTerm,
meta: { throttle: 300 } // Throttle to max once per 300ms
});
// Debounce middleware
const debounceMiddleware = () => {
const timers = new Map();
return store => next => action => {
const time = action.meta?.debounce;
if (!time) {
return next(action);
}
const key = action.type;
// Clear existing timer
if (timers.has(key)) {
clearTimeout(timers.get(key));
}
// Set new timer
const timer = setTimeout(() => {
timers.delete(key);
next(action);
}, time);
timers.set(key, timer);
};
};
Real-World Example: Notification System
// Notification middleware for handling app-wide notifications
const notificationMiddleware = store => next => action => {
const result = next(action);
// Auto-generate notifications for certain actions
switch (action.type) {
case 'USER_LOGIN_SUCCESS':
store.dispatch({
type: 'SHOW_NOTIFICATION',
payload: {
id: Date.now(),
type: 'success',
message: `Welcome back, ${action.payload.user.name}!`,
duration: 3000
}
});
break;
case 'API_ERROR':
store.dispatch({
type: 'SHOW_NOTIFICATION',
payload: {
id: Date.now(),
type: 'error',
message: action.payload.message || 'An error occurred',
duration: 5000
}
});
break;
case 'FILE_UPLOAD_SUCCESS':
store.dispatch({
type: 'SHOW_NOTIFICATION',
payload: {
id: Date.now(),
type: 'success',
message: 'File uploaded successfully',
duration: 3000,
action: {
label: 'View',
onClick: () => window.open(action.payload.fileUrl)
}
}
});
break;
}
// Auto-dismiss notifications
if (action.type === 'SHOW_NOTIFICATION' && action.payload.duration) {
setTimeout(() => {
store.dispatch({
type: 'HIDE_NOTIFICATION',
payload: action.payload.id
});
}, action.payload.duration);
}
return result;
};
// Notification reducer
const notificationsReducer = (state = [], action) => {
switch (action.type) {
case 'SHOW_NOTIFICATION':
return [...state, action.payload];
case 'HIDE_NOTIFICATION':
return state.filter(n => n.id !== action.payload);
case 'CLEAR_NOTIFICATIONS':
return [];
default:
return state;
}
};
// Notification component
const NotificationCenter = () => {
const notifications = useSelector(state => state.notifications);
const dispatch = useDispatch();
return (
<div className="notification-center">
{notifications.map(notification => (
<div
key={notification.id}
className={`notification ${notification.type}`}
>
<p>{notification.message}</p>
{notification.action && (
<button onClick={notification.action.onClick}>
{notification.action.label}
</button>
)}
<button onClick={() => dispatch({
type: 'HIDE_NOTIFICATION',
payload: notification.id
})}>
×
</button>
</div>
))}
</div>
);
};
Testing Middleware
// Testing middleware
describe('logger middleware', () => {
let store;
let next;
let action;
beforeEach(() => {
store = {
getState: jest.fn(() => ({ test: 'state' }))
};
next = jest.fn();
action = { type: 'TEST_ACTION' };
console.log = jest.fn();
console.group = jest.fn();
console.groupEnd = jest.fn();
});
it('should log action and state', () => {
const middleware = logger(store)(next);
middleware(action);
expect(console.group).toHaveBeenCalledWith('TEST_ACTION');
expect(console.log).toHaveBeenCalledWith('dispatching', action);
expect(next).toHaveBeenCalledWith(action);
expect(console.log).toHaveBeenCalledWith('next state', { test: 'state' });
expect(console.groupEnd).toHaveBeenCalled();
});
it('should return the result of next', () => {
const middleware = logger(store)(next);
next.mockReturnValue('result');
const result = middleware(action);
expect(result).toBe('result');
});
});
// Testing async middleware
describe('api middleware', () => {
let store;
let next;
beforeEach(() => {
store = { dispatch: jest.fn(), getState: jest.fn() };
next = jest.fn();
global.fetch = jest.fn();
});
it('should handle API_REQUEST actions', async () => {
const action = {
type: 'API_REQUEST',
payload: {
url: '/api/test',
method: 'GET',
feature: 'TEST'
}
};
fetch.mockResolvedValueOnce({
json: () => Promise.resolve({ data: 'test' })
});
const middleware = apiMiddleware(store)(next);
await middleware(action);
expect(next).toHaveBeenCalledWith({ type: 'TEST_REQUEST' });
expect(fetch).toHaveBeenCalledWith('/api/test', expect.any(Object));
// Wait for async operations
await new Promise(resolve => setTimeout(resolve, 0));
expect(next).toHaveBeenCalledWith({
type: 'TEST_SUCCESS',
payload: { data: 'test' }
});
});
});
Practice Exercise
Task: Create a Caching Middleware
Create middleware that caches API responses to avoid redundant network requests:
- Cache successful API responses
- Serve cached data for repeated requests
- Implement cache expiration
- Allow cache invalidation
- Handle cache size limits
// TODO: Implement caching middleware
const cacheMiddleware = (options = {}) => {
const {
maxAge = 5 * 60 * 1000, // 5 minutes default
maxSize = 100 // Maximum cache entries
} = options;
const cache = new Map();
return store => next => action => {
// TODO: Implement caching logic
// 1. Check if action is cacheable
// 2. Check cache for existing data
// 3. Return cached data if valid
// 4. Otherwise, proceed with request
// 5. Cache the response
// 6. Implement cache cleanup
};
};
// Usage
const store = createStore(
rootReducer,
applyMiddleware(
cacheMiddleware({ maxAge: 10 * 60 * 1000 }),
apiMiddleware
)
);
// Example action that should be cached
dispatch({
type: 'API_REQUEST',
payload: {
url: '/api/products',
method: 'GET',
feature: 'PRODUCTS'
},
meta: {
cache: true,
cacheKey: 'products-list'
}
});