🛠️ 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
localStoragefrom middleware - Write a configurable middleware using the factory pattern
- Implement a rate limiter and an analytics tracker
- Unit-test middleware with a mocked
storeandnext
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.
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/catchso they never break dispatch
❌ Don't
- Don't mutate the incoming
actionor the state object directly - Don't run heavy synchronous work — defer it or offload to a Web Worker
- Don't
dispatchthe 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 onmockNext
📚 Additional Resources
- Redux — Middleware (concepts & design)
- Redux API — applyMiddleware
- Redux — Writing Tests (Middleware)
- redux-persist — production state persistence
🚀 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.