Redux Internals: How It Really Works
A deep dive into Redux's core implementation - understanding createStore, middleware pipeline, and the subscription model that powers predictable state management.
Redux is deceptively simple. The entire core is ~200 lines of code. Let’s understand how it actually works under the hood.
The Core: createStore
At its heart, Redux is just a closure that holds state:
function createStore(reducer, preloadedState, enhancer) {
let currentState = preloadedState;
let currentReducer = reducer;
let currentListeners = [];
let nextListeners = currentListeners;
let isDispatching = false;
function getState() {
return currentState;
}
function subscribe(listener) {
nextListeners.push(listener);
return function unsubscribe() {
const index = nextListeners.indexOf(listener);
nextListeners.splice(index, 1);
};
}
function dispatch(action) {
currentState = currentReducer(currentState, action);
const listeners = (currentListeners = nextListeners);
listeners.forEach(listener => listener());
return action;
}
// Initialize state
dispatch({ type: '@@redux/INIT' });
return { getState, subscribe, dispatch };
}
That’s the essence. Everything else is built on top of this pattern.
The Subscription Model
Redux uses a simple pub/sub pattern. The key insight is the nextListeners copy:
function ensureCanMutateNextListeners() {
if (nextListeners === currentListeners) {
nextListeners = currentListeners.slice();
}
}
This prevents bugs when listeners subscribe/unsubscribe during dispatch.
Middleware: The compose Pattern
Middleware is Redux’s killer feature. It’s implemented using function composition:
function applyMiddleware(...middlewares) {
return (createStore) => (reducer, preloadedState) => {
const store = createStore(reducer, preloadedState);
const middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action)
};
const chain = middlewares.map(middleware => middleware(middlewareAPI));
const dispatch = compose(...chain)(store.dispatch);
return { ...store, dispatch };
};
}
The compose function chains functions right-to-left:
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)));
}
Middleware Anatomy
Every middleware has this signature:
const middleware = store => next => action => {
// Before dispatch
console.log('dispatching', action);
const result = next(action); // Call next middleware
// After dispatch
console.log('next state', store.getState());
return result;
};
The triple arrow function creates a pipeline where each middleware can:
- Inspect/modify actions before they reach the reducer
- Delay or skip calling
next(action) - Dispatch additional actions
Redux Thunk: 14 Lines of Power
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
const thunk = createThunkMiddleware();
If action is a function, call it with dispatch/getState. Otherwise, pass it along.
combineReducers Implementation
function combineReducers(reducers) {
const reducerKeys = Object.keys(reducers);
return function combination(state = {}, action) {
const nextState = {};
let hasChanged = false;
for (const key of reducerKeys) {
const reducer = reducers[key];
const previousStateForKey = state[key];
const nextStateForKey = reducer(previousStateForKey, action);
nextState[key] = nextStateForKey;
hasChanged = hasChanged || nextStateForKey !== previousStateForKey;
}
return hasChanged ? nextState : state;
};
}
Key insight: it returns the same state object reference if nothing changed, enabling efficient React re-renders.
Why Immutability Matters
Redux relies on reference equality checks:
// In React-Redux's connect()
if (nextState !== previousState) {
// Trigger re-render
}
Mutating state breaks this check. That’s why reducers must return new objects.
Performance Considerations
- Selector Memoization: Use
reselectto avoid recomputing derived data - Normalized State: Store entities by ID to enable O(1) lookups
- Batch Dispatches: Multiple dispatches cause multiple renders
Modern Redux: Redux Toolkit
RTK simplifies Redux with sensible defaults:
import { configureStore, createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => { state.value += 1 }, // Immer handles immutability
decrement: state => { state.value -= 1 },
},
});
const store = configureStore({
reducer: { counter: counterSlice.reducer },
});
Conclusion
Redux’s power comes from its simplicity:
- Single source of truth (one store)
- State is read-only (actions describe changes)
- Pure reducers (predictable updates)
Understanding these internals helps you debug issues, write better middleware, and appreciate why the patterns exist.