Back to Deep Dives
State Management
Madhusudhan Kakurala 4 min read

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:

  1. Inspect/modify actions before they reach the reducer
  2. Delay or skip calling next(action)
  3. 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

  1. Selector Memoization: Use reselect to avoid recomputing derived data
  2. Normalized State: Store entities by ID to enable O(1) lookups
  3. 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.