Skip to main content

🛠️ Custom Middleware

Thunk is just one middleware someone wrote using the store => next => action pattern — and you can write your own just as easily. Custom middleware is where you add app-wide behavior that touches every action: automatic timestamps, validation, saving state to localStorage, rate limiting, analytics. This lesson turns you from a middleware user into a middleware author.

Week 6 · Day 3 (Wednesday: Redux Middleware) · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Structure a custom middleware and choose its "before" vs "after" phase
  • Transform and validate actions before they reach the reducer
  • Persist selected state slices to localStorage from middleware
  • Write a configurable middleware using the factory pattern
  • Implement a rate limiter and an analytics tracker
  • Unit-test middleware with a mocked store and next

Estimated Time: 65 minutes

Practice: Build a validation middleware and a configurable rate limiter, then test them.

In This Lesson

Why Write Your Own?

Redux ships with almost nothing built in — that minimalism is the point. Cross-cutting behavior that should apply to every dispatched action, regardless of which reducer handles it, belongs in middleware. Logging, authentication, persistence, analytics, and rate limiting are all "every action" concerns, and none of them belongs in a reducer.

🛠️ The Assembly Line Analogy

Think of your dispatch pipeline as a factory assembly line. Actions are raw parts entering one end; the reducer is the packaging station at the far end. Custom middleware are the workstations you bolt onto the line: one stamps a timestamp on every part, one inspects parts and rejects the defective ones, one photographs each part for the analytics archive, and one throttles how fast parts may enter. Each station does one job and passes the part along — and you decide the order.

Anatomy of Custom Middleware

Every custom middleware is the same curried shape you have seen twice now. The key design decision is where your logic sits relative to next(action): code before it runs before the reducer sees the action; code after it runs once state has already updated.

graph TD A[Action Dispatched] --> B[Your Middleware] B --> C{Decide} C -->|Modify| D["next(modifiedAction)"] C -->|Side effect| E["run effect, then next(action)"] C -->|Block| F["return early — never call next"] D --> G[Next Middleware] E --> G G --> H[Reducer]
const customMiddleware = store => next => action => {
  // ── "before" phase: runs before the reducer ──
  console.log('Before:', action);

  if (action.type === 'SPECIAL_ACTION') {
    // inspect, modify, or short-circuit here
  }

  const result = next(action);   // hand off to the next link / reducer

  // ── "after" phase: state has now updated ──
  console.log('After:', store.getState());

  return result;   // always return what next() returned
};

// What you have access to:
//   store.getState()  — read current state
//   store.dispatch()  — fire a NEW action (never the same one!)
//   next              — pass the action forward
//   action            — the action in flight

Transforming & Validating Actions

1. Stamp a timestamp on every action

A transformation middleware replaces the action with an enriched copy — always by spreading into a new object, never by mutating the original.

const timestampMiddleware = store => next => action => {
  const stamped = {
    ...action,
    meta: { ...action.meta, timestamp: Date.now() }
  };
  return next(stamped);   // reducer receives the stamped action
};

2. Reject invalid actions

A validation middleware guards the reducer, turning bad input into a clean error action instead of corrupt state. Because it can call next with a replacement, the invalid action never reaches any reducer.

const actionValidator = store => next => action => {
  // Every action must be a plain object with a type
  if (!action || typeof action !== 'object') {
    throw new Error('Actions must be plain objects');
  }
  if (typeof action.type === 'undefined') {
    throw new Error('Actions must have a type property');
  }

  // Type-specific rules
  if (action.type === 'ADD_USER') {
    const { name, email } = action.payload ?? {};
    if (!name || !email) {
      return next({ type: 'ADD_USER_ERROR', payload: 'Name and email are required', error: true });
    }
    const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
    if (!emailOk) {
      return next({ type: 'ADD_USER_ERROR', payload: 'Invalid email format', error: true });
    }
  }

  return next(action);
};

💡 Modify vs block vs redirect

A middleware has exactly three moves: modify (call next with a changed action), block (return without ever calling next), or redirect (call next with a different action entirely). Everything fancy is a combination of these three.

Persistence Middleware

A classic "after" phase job: once state has updated, write the parts worth keeping to localStorage so they survive a page refresh. Notice how next(action) comes first — we want the new state, not the old one.

const persistenceMiddleware = store => next => action => {
  const result = next(action);   // let the reducer update state FIRST

  // Only persist after actions that change data worth keeping
  if (/^(AUTH_|PREFERENCES_|CART_)/.test(action.type)) {
    const state = store.getState();
    const toSave = {
      auth: { token: state.auth.token, user: state.auth.user },
      preferences: { theme: state.preferences.theme },
      cart: { items: state.cart.items }
    };
    try {
      localStorage.setItem('app_state', JSON.stringify(toSave));
    } catch (err) {
      console.error('Failed to persist state:', err);
    }
  }

  return result;
};

// On startup, hydrate the store from what we saved
const loadPersistedState = () => {
  try {
    const saved = localStorage.getItem('app_state');
    return saved ? JSON.parse(saved) : undefined;
  } catch {
    return undefined;
  }
};

const store = createStore(
  rootReducer,
  loadPersistedState(),                       // preloaded state
  applyMiddleware(persistenceMiddleware)
);

⚠️ Don't reinvent redux-persist

The example above is great for learning the mechanics, but for production the community-standard redux-persist handles rehydration, versioning, and migrations for you. Write your own to understand it; reach for the library to ship it.

Configurable Middleware (Factory Pattern)

When a middleware needs options or private state (a cache, a counter, a map of timers), wrap it in an outer function — a factory — that takes the config and returns the middleware. You call the factory inside applyMiddleware.

A rate limiter

This factory keeps a private Map of call counts per time window and rejects actions that exceed their configured limit.

const rateLimiter = (config = {}) => {
  const counts = new Map();                 // private state, one per factory call
  const { defaultLimit = 10, windowMs = 60000 } = config;

  return store => next => action => {
    const rule = config.limits?.[action.type];
    if (!rule) return next(action);         // not rate-limited — pass through

    const limit = rule.limit ?? defaultLimit;
    const win = rule.windowMs ?? windowMs;
    const key = `${action.type}_${Math.floor(Date.now() / win)}`;
    const current = counts.get(key) ?? 0;

    if (current >= limit) {
      console.warn(`Rate limit exceeded for ${action.type}`);
      return next({ type: 'RATE_LIMIT_EXCEEDED', payload: { actionType: action.type, limit }, error: true });
    }

    counts.set(key, current + 1);
    return next(action);
  };
};

// Usage — note the extra call: rateLimiter({...}) RETURNS the middleware
const store = createStore(
  rootReducer,
  applyMiddleware(
    rateLimiter({
      limits: {
        API_REQUEST:  { limit: 100, windowMs: 60000 },
        SEND_MESSAGE: { limit: 5,   windowMs: 10000 }
      }
    })
  )
);

✅ Four arrows, not three

A configurable middleware is config => store => next => action. The outer arrow runs once when you set up the store and is where private state (like counts) lives. The inner three are the normal middleware. This is the same shape as thunk.withExtraArgument(arg).

Analytics Middleware

A realistic analytics middleware straddles next(action): it captures state before, measures how long the reducer took, and reports afterward — filtering out noisy internal actions and honoring a sample rate.

const analyticsMiddleware = (analytics, config = {}) => {
  const {
    ignore = ['@@redux/', 'persist/'],
    sampleRate = 1.0,
    getUserId = (state) => state.auth?.user?.id
  } = config;

  return store => next => action => {
    if (ignore.some(prefix => action.type.startsWith(prefix))) return next(action);
    if (Math.random() > sampleRate) return next(action);   // sampling

    const start = performance.now();
    const result = next(action);                           // reducer runs
    const duration = performance.now() - start;

    try {
      analytics.track('redux_action', {
        type: action.type,
        durationMs: Number(duration.toFixed(2)),
        userId: getUserId(store.getState()),
        timestamp: new Date().toISOString()
      });
      // High-value events get their own richer event
      if (action.type === 'CHECKOUT_SUCCESS') {
        analytics.track('purchase', { orderId: action.payload.orderId, total: action.payload.total });
      }
    } catch (err) {
      console.error('Analytics error:', err);              // never let tracking break the app
    }

    return result;
  };
};

Testing Middleware

Middleware is a pure-ish function of store, next, and action — so testing it is just supplying mocks for those three and asserting on what next received. No real store required.

describe('timestampMiddleware', () => {
  let store, next;

  beforeEach(() => {
    store = { getState: jest.fn(() => ({})), dispatch: jest.fn() };
    next  = jest.fn(action => action);   // stand-in for the next link
  });

  it('adds a timestamp to the action', () => {
    const handler = timestampMiddleware(store)(next);   // apply store, then next
    handler({ type: 'TEST_ACTION' });

    expect(next).toHaveBeenCalledWith(
      expect.objectContaining({
        type: 'TEST_ACTION',
        meta: expect.objectContaining({ timestamp: expect.any(Number) })
      })
    );
  });

  it('preserves existing meta fields', () => {
    const handler = timestampMiddleware(store)(next);
    handler({ type: 'TEST_ACTION', meta: { existing: 'value' } });

    expect(next).toHaveBeenCalledWith(
      expect.objectContaining({
        meta: expect.objectContaining({ existing: 'value', timestamp: expect.any(Number) })
      })
    );
  });
});

The testing recipe in three lines

1. const handler = middleware(mockStore)(mockNext);
2. handler(someAction);
3. expect(mockNext).toHaveBeenCalledWith(theExpectedAction);

Practice & Quiz

🏋️ Exercise 1: Require a payload

Goal: Write requirePayload, a middleware that blocks any action whose type ends in _SUBMIT but has no payload, redirecting to a { type: 'MISSING_PAYLOAD', error: true } action.

const requirePayload = store => next => action => {
    // TODO: if type ends in _SUBMIT and payload is missing,
    //       next() a MISSING_PAYLOAD error action instead
};
💡 Hint

Use action.type.endsWith('_SUBMIT') and check action.payload == null. When invalid, return next({ ... }) with the error action; otherwise return next(action).

✅ Solution
const requirePayload = store => next => action => {
    if (action.type.endsWith('_SUBMIT') && action.payload == null) {
        return next({
            type: 'MISSING_PAYLOAD',
            payload: `${action.type} requires a payload`,
            error: true
        });
    }
    return next(action);
};

🏋️ Exercise 2: A configurable logger

Goal: Write a logger factory that takes { collapsed } and returns a middleware logging each action's type — using console.groupCollapsed when collapsed is true, else console.group.

✅ Solution
const logger = ({ collapsed = false } = {}) => store => next => action => {
    const group = collapsed ? console.groupCollapsed : console.group;
    group(action.type);
    const result = next(action);
    console.log('next state', store.getState());
    console.groupEnd();
    return result;
};

// Usage: applyMiddleware(logger({ collapsed: true }))

🎯 Quick Quiz

Question 1: To read the updated state after the reducer ran, where do you call store.getState()?

Question 2: Why wrap a middleware in a factory function like rateLimiter(config)?

Question 3: How do you unit-test a middleware without a real store?

Best Practices & Pitfalls

✅ Do

  • Give each middleware a single responsibility and a clear name
  • Always call next(action) unless you are intentionally blocking
  • Treat actions and state as immutable — spread into new objects
  • Make middleware configurable with the factory pattern when it needs options
  • Wrap side-effect calls (analytics, storage) in try/catch so they never break dispatch

❌ Don't

  • Don't mutate the incoming action or the state object directly
  • Don't run heavy synchronous work — defer it or offload to a Web Worker
  • Don't dispatch the same action you received (infinite loop)
  • Don't make middleware depend on the order of unrelated actions

⚠️ Keep heavy work off the dispatch thread

// ❌ Blocks every dispatch while it computes
const bad = store => next => action => {
  const result = heavyComputation(store.getState());
  return next({ ...action, computed: result });
};

// ✅ Defer, then dispatch a follow-up action when done
const good = store => next => action => {
  if (action.type === 'COMPUTE_HEAVY') {
    Promise.resolve().then(() => {
      const result = heavyComputation(store.getState());
      store.dispatch({ type: 'COMPUTATION_COMPLETE', payload: result });
    });
  }
  return next(action);   // let the original action through immediately
};

Summary

🎉 Key Takeaways

  • Custom middleware handles every-action, cross-cutting concerns the reducer shouldn't touch
  • Logic before next(action) runs pre-reducer; logic after sees the updated state
  • A middleware's only moves are modify, block, or redirect the action
  • Wrap it in a factory (config => store => next => action) when it needs options or private state
  • Test it by calling middleware(mockStore)(mockNext)(action) and asserting on mockNext

📚 Additional Resources

🚀 What's Next?

You have now written middleware, reducers, and store setup by hand — and seen how much boilerplate that involves. The next lesson introduces the tool that eliminates most of it: Introduction to Redux Toolkit, the officially recommended way to write Redux, with configureStore, createSlice, and the async and middleware helpers you've been building by hand.

🎉 You're a middleware author now!

The store => next => action pattern is fully yours — you can extend Redux with any behavior your app needs.