Inside createStore: Closures, Listeners, and the Dispatch Cycle
Prerequisites
- ›Article 1: Architecture and Project Layout
- ›JavaScript closures and higher-order functions
- ›The observer pattern (subscribe/unsubscribe)
Inside createStore: Closures, Listeners, and the Dispatch Cycle
In the previous article we established that Redux is a closure-based state container — not a class hierarchy. Now we crack open the closure itself. createStore.ts is roughly 500 lines, but about half of that is JSDoc comments and type overloads. The runtime logic is surprisingly compact, and every line exists for a reason.
We'll trace the complete lifecycle: how arguments are validated and shuffled, how the enhancer pattern short-circuits store creation, how dispatch drives state transitions and listener notification, and the subtle dual-Map snapshotting mechanism that makes mid-dispatch subscription changes safe.
Function Signature and Argument Shuffling
createStore accepts two or three arguments: (reducer, preloadedState?, enhancer?). But there's a common shorthand — passing the enhancer as the second argument when there's no preloaded state. The function handles this with a simple type check:
flowchart TD
A["createStore(reducer, arg2?, arg3?)"] --> B{"typeof arg2 === 'function'<br/>AND arg3 === undefined?"}
B -- Yes --> C["enhancer = arg2<br/>preloadedState = undefined"]
B -- No --> D{"Multiple enhancers<br/>detected?"}
D -- Yes --> E["throw Error:<br/>'compose them together'"]
D -- No --> F["enhancer = arg3<br/>preloadedState = arg2"]
C --> G["Continue to enhancer check"]
F --> G
This pattern is pragmatic — it saves consumers from writing createStore(reducer, undefined, enhancer) — but it introduces complexity. The function also guards against a common mistake: passing multiple enhancers as separate arguments instead of composing them with compose().
The deprecation story is handled through TypeScript's @deprecated JSDoc tag on createStore, while legacy_createStore is an identical function without the tag:
It literally calls createStore with the same arguments. The only difference is what your IDE shows.
The Six Closure Variables
As we previewed in Article 1, the store's private state lives in six let bindings:
| Variable | Type | Purpose |
|---|---|---|
currentReducer |
Reducer<S, A> |
The active root reducer; swapped by replaceReducer |
currentState |
S | PreloadedState | undefined |
The current state tree |
currentListeners |
Map<number, ListenerCallback> | null |
Snapshot of listeners used during dispatch |
nextListeners |
Map<number, ListenerCallback> |
Working copy where subscribe/unsubscribe mutations happen |
listenerIdCounter |
number |
Monotonically increasing ID for stable listener identity |
isDispatching |
boolean |
Reentrancy guard preventing dispatch-during-reduce |
These six variables are the only mutable state in the entire system. Every method on the returned store object — dispatch, subscribe, getState, replaceReducer — reads from and writes to these same six variables through closure.
Tip: The
currentStatetype isS | PreloadedState | undefined, not justS. This reflects the reality that before the INIT dispatch, the state may be the raw preloaded value (which could have a different shape, e.g.,Partial<S>). After INIT, the reducer guarantees it'sS. ThegetState()method casts the return toS, which is a small but deliberate lie for ergonomics.
The Enhancer Short-Circuit
When an enhancer is provided, createStore does something surprising — it returns immediately, delegating all store creation to the enhancer:
sequenceDiagram
participant App
participant createStore
participant enhancer
participant createStore2 as createStore (round 2)
App->>createStore: (reducer, enhancer)
createStore->>enhancer: enhancer(createStore)
enhancer->>createStore2: createStore(reducer, preloadedState)
Note over createStore2: No enhancer this time —<br/>runs the full function
createStore2-->>enhancer: base store
enhancer-->>App: enhanced store
The call enhancer(createStore)(reducer, preloadedState) means the enhancer receives createStore itself as an argument. It can then call it to get a base store, modify it, and return the modified version. This is the fundamental mechanism that powers applyMiddleware.
Notice the recursive structure: createStore is called twice — once by your app (with the enhancer), and once by the enhancer (without). The second call runs through the full function body and creates the actual store.
dispatch(): The Core Cycle
The dispatch function is where state actually changes. Let's trace it step by step:
sequenceDiagram
participant Caller
participant dispatch
participant isPlainObject
participant Reducer
participant Listeners
Caller->>dispatch: dispatch(action)
dispatch->>isPlainObject: isPlainObject(action)?
alt Not a plain object
isPlainObject-->>dispatch: false
dispatch-->>Caller: throw Error
end
dispatch->>dispatch: typeof action.type === 'string'?
dispatch->>dispatch: isDispatching === false?
dispatch->>dispatch: isDispatching = true
dispatch->>Reducer: currentReducer(currentState, action)
Reducer-->>dispatch: nextState
dispatch->>dispatch: isDispatching = false
dispatch->>dispatch: currentListeners = nextListeners
dispatch->>Listeners: forEach(listener => listener())
dispatch-->>Caller: return action
The validations are strict: actions must be plain objects (not class instances, Promises, or other exotic types), must have a type property, and that type must be a string. Redux v5 tightened this last check — earlier versions accepted symbols and other types.
The isPlainObject check is worth examining:
src/utils/isPlainObject.ts#L5-L16
It walks the prototype chain to the root, then checks if the object's direct prototype matches. This correctly identifies Object.create(null) objects (no prototype) and standard {} objects, while rejecting class instances, arrays, and other complex objects.
The isDispatching guard prevents reducers from calling dispatch during execution — a reentrancy that would create infinite loops. The try/finally ensures the flag is reset even if the reducer throws.
After the reducer returns, the critical line const listeners = (currentListeners = nextListeners) atomically snapshots the listener list and iterates it. This brings us to the most subtle part of createStore.
Listener Snapshotting: The Dual-Map Pattern
Redux must handle a tricky scenario: a listener that unsubscribes itself (or other listeners) during the notification loop. If you iterate a list and remove elements mid-iteration, you'll skip listeners or get index errors.
The solution is a copy-on-write system with two Map instances:
currentListeners is the snapshot used during dispatch. nextListeners is where mutations (subscribe/unsubscribe) happen. The ensureCanMutateNextListeners function lazily copies currentListeners into a new Map only when a mutation is about to happen:
flowchart TD
A["subscribe(listener)"] --> B{"nextListeners === currentListeners?"}
B -- Yes --> C["Copy currentListeners to new Map"]
B -- No --> D["Already diverged, safe to mutate"]
C --> D
D --> E["nextListeners.set(id, listener)"]
F["dispatch(action)"] --> G["currentListeners = nextListeners"]
G --> H["Iterate currentListeners"]
H --> I["Listeners may call subscribe/unsubscribe"]
I --> J["Mutations go to nextListeners<br/>(diverged from currentListeners)"]
Redux v5 switched from arrays to Map for the listener collection. Arrays had a subtle bug: when a listener unsubscribed during dispatch, the array splice would shift indices and potentially cause a different listener to be skipped. Map.forEach doesn't have this problem because it visits entries by insertion order and handles deletions gracefully — but Redux doesn't even need to rely on that, because mutations always go to the other Map.
The tests prove this behavior explicitly:
test/createStore.spec.ts#L290-L344
The test "notifies all subscribers about current dispatch regardless if any of them gets unsubscribed in the process" sets up three listeners where listener2 unsubscribes all three during dispatch. All three still fire for that dispatch — because they were in the snapshot — and none fire on the next dispatch.
Tip: Setting
currentListeners = nullin the unsubscribe function (line 251) is a memory optimization. After unsubscribing, the old snapshot is no longer needed by dispatch, so nulling the reference allows garbage collection.
Randomized Internal Action Types
When createStore initializes, it dispatches a special @@redux/INIT action to populate initial state. But what if a user's reducer has a case for "@@redux/INIT"? That would be a bug — their reducer would accidentally intercept an internal action.
The solution is randomization:
src/utils/actionTypes.ts#L1-L17
INIT becomes something like @@redux/INIT3.h.j.2.k — different every time the module loads. PROBE_UNKNOWN_ACTION is even more extreme: it's a function that generates a new random suffix on each call, used by combineReducers to verify that reducers handle unknown actions correctly (more on that in Article 3).
The /* #__PURE__ */ annotation on randomString() tells minifiers that this call has no side effects, enabling better dead code elimination.
replaceReducer and the Observable Protocol
Two methods round out the store API. replaceReducer swaps the root reducer and dispatches a @@redux/REPLACE action to re-initialize state:
This enables hot module replacement: when your bundler hot-swaps a reducer module, the store can adopt the new reducer without losing state.
The observable method implements the TC39 Observable protocol via Symbol.observable, enabling interop with RxJS and other reactive libraries:
sequenceDiagram
participant RxJS
participant Store Observable
participant subscribe
participant getState
RxJS->>Store Observable: subscribe(observer)
Store Observable->>getState: getState()
Store Observable->>RxJS: observer.next(initialState)
Store Observable->>subscribe: subscribe(observeState)
Note over subscribe: On every dispatch...
subscribe->>getState: getState()
subscribe->>RxJS: observer.next(newState)
The $$observable symbol is resolved at import time from symbol-observable.ts, which checks for native Symbol.observable support and falls back to the @@observable string convention.
Finally, the store object is assembled and returned as a plain object literal:
No class instantiation. No new. Just an object with five methods closed over six mutable variables.
What's Next
We've traced the complete lifecycle of createStore — from argument parsing through the dispatch cycle to listener notification. In the next article, we'll look at combineReducers: how it validates slice reducers at creation time, the reference equality trick that avoids unnecessary object creation at runtime, and the compose utility that underpins the entire middleware system.