Understanding Redux Middleware

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'
  }
});
                

Additional Resources