Redux in 1,575 Lines: Architecture Overview and Project Navigation
Prerequisites
- ›Basic JavaScript and ES modules
- ›Experience using Redux as a consumer (actions, reducers, store)
Redux in 1,575 Lines: Architecture Overview and Project Navigation
Redux has shaped how an entire generation of JavaScript developers thinks about state management. Yet behind the ecosystem of toolkits, middlewares, and dev tools sits a core library that fits in 17 source files and exports exactly 9 runtime values. Understanding those 17 files is not just an exercise in code archaeology — it reveals design principles that apply far beyond Redux itself: closure-based encapsulation, functional composition, and the power of a deliberately minimal API surface.
This article maps the territory. We'll walk through every public export, trace the file-to-API mapping, sketch the type system hierarchy, unpack the multi-format build configuration, and arrive at the core architectural insight that makes Redux tick.
The Public API Surface
Everything a consumer can import from redux is declared in a single barrel file. Let's look at it in its entirety:
Nine runtime exports. That's it. Here they are, categorized:
| Export | Category | Purpose |
|---|---|---|
createStore |
Core | Creates the store (deprecated in favor of RTK's configureStore) |
legacy_createStore |
Core | Same function, no deprecation warning in editors |
combineReducers |
Composition | Merges a map of slice reducers into a single root reducer |
applyMiddleware |
Composition | Creates a store enhancer from middleware functions |
compose |
Utility | Right-to-left function composition |
bindActionCreators |
Utility | Wraps action creators so they auto-dispatch |
isAction |
Guard | Type guard: checks if a value is a Redux action |
isPlainObject |
Guard | Checks if a value is a plain {} object |
__DO_NOT_USE__ActionTypes |
Escape hatch | Internal action types, exposed only for DevTools |
The naming of __DO_NOT_USE__ActionTypes is deliberately hostile — it's a signal to library consumers that this export exists solely for the Redux DevTools extension, which needs to know the randomized @@redux/INIT and @@redux/REPLACE action type strings. No application code should ever reference it.
The legacy_createStore alias deserves attention too. When the Redux team deprecated createStore to nudge users toward Redux Toolkit, they needed a way to let holdouts opt out of IDE strikethrough warnings. The solution: a second named export pointing to the exact same implementation.
Tip: If you're reading an unfamiliar codebase, always start at the barrel export. It tells you what the library wants you to use, which is usually a small fraction of what it contains.
Directory Structure and File-to-API Mapping
Redux exhibits a rare 1:1 mapping between source files and public API functions. There's no indirection, no factory-of-factories — each file owns a single concern.
| Source File | Public Export | Lines |
|---|---|---|
src/createStore.ts |
createStore, legacy_createStore |
~500 |
src/combineReducers.ts |
combineReducers |
~200 |
src/applyMiddleware.ts |
applyMiddleware |
~77 |
src/compose.ts |
compose |
~61 |
src/bindActionCreators.ts |
bindActionCreators |
~83 |
src/types/actions.ts |
Type definitions | ~75 |
src/types/reducers.ts |
Type definitions | ~115 |
src/types/store.ts |
Type definitions | ~233 |
src/types/middleware.ts |
Type definitions | ~31 |
src/utils/actionTypes.ts |
__DO_NOT_USE__ActionTypes |
~17 |
src/utils/isAction.ts |
isAction |
~10 |
src/utils/isPlainObject.ts |
isPlainObject |
~16 |
src/utils/kindOf.ts |
Internal only | ~70 |
src/utils/warning.ts |
Internal only | ~18 |
src/utils/symbol-observable.ts |
Internal only | ~10 |
src/utils/formatProdErrorMessage.ts |
Internal only | ~13 |
graph TD
subgraph "Public API (src/)"
CS[createStore.ts]
CR[combineReducers.ts]
AM[applyMiddleware.ts]
CO[compose.ts]
BA[bindActionCreators.ts]
end
subgraph "Types (src/types/)"
TA[actions.ts]
TR[reducers.ts]
TS[store.ts]
TM[middleware.ts]
end
subgraph "Utils (src/utils/)"
AT[actionTypes.ts]
IA[isAction.ts]
IP[isPlainObject.ts]
KO[kindOf.ts]
WA[warning.ts]
SO[symbol-observable.ts]
FP[formatProdErrorMessage.ts]
end
CS --> AT
CS --> IP
CS --> KO
CS --> SO
CR --> AT
CR --> IP
CR --> WA
CR --> KO
AM --> CO
BA --> KO
IA --> IP
The utils/ directory is the internal-only layer. Functions like kindOf (rich type descriptions for error messages) and warning (console.error with a debugger-friendly throw) are never exposed to consumers. The formatProdErrorMessage utility is particularly interesting — it exists solely as a build-time replacement target, as we'll explore in the final article of this series.
The Type System Layers
Redux's TypeScript types are organized as a dependency chain across four files, each building on the previous:
flowchart LR
A["actions.ts<br/><i>Action, UnknownAction</i>"] --> B["reducers.ts<br/><i>Reducer, ReducersMapObject</i>"]
B --> C["store.ts<br/><i>Store, StoreEnhancer, Dispatch</i>"]
C --> D["middleware.ts<br/><i>Middleware, MiddlewareAPI</i>"]
The foundation is Action<T> — a type (not an interface, for TypeScript structural reasons) that requires only a type: string property:
UnknownAction extends it with an index signature [extraProps: string]: unknown, while the deprecated AnyAction uses any instead. The Reducer type consumes actions and state:
The Store interface assembles everything — dispatch, getState, subscribe, replaceReducer, and the observable protocol:
At the apex sits StoreEnhancer, whose Ext and StateExt generics enable type-safe composition of extensions. We'll dissect this in detail in Article 5.
Package Exports and Build Formats
Redux ships four distinct bundle formats from a single source. The package.json exports field and legacy fields control which format each consumer receives:
| Field | File | Format | Consumer |
|---|---|---|---|
exports["."].import |
dist/redux.mjs |
ESM | Modern bundlers (Vite, webpack 5, Rollup) |
exports["."].default |
dist/cjs/redux.cjs |
CJS | Node.js require(), older tooling |
module |
dist/redux.legacy-esm.js |
Legacy ESM (.js) | Webpack 4, which reads module but doesn't understand .mjs |
| (build only) | dist/redux.browser.mjs |
Browser ESM (minified) | Direct <script type="module"> usage |
flowchart TD
SRC["src/index.ts"] --> TSUP["tsup / esbuild"]
TSUP --> ESM["redux.mjs<br/><i>ESM, dev+prod guards</i>"]
TSUP --> LEGACY["redux.legacy-esm.js<br/><i>ESM for Webpack 4</i>"]
TSUP --> BROWSER["redux.browser.mjs<br/><i>ESM, minified, prod-only</i>"]
TSUP --> CJS["cjs/redux.cjs<br/><i>CommonJS</i>"]
The sideEffects: false declaration on line 84 of package.json is critical — it tells bundlers that every export can be tree-shaken safely. If you only import compose, bundlers can eliminate createStore, combineReducers, and everything else.
Tip: The browser bundle hard-codes
process.env.NODE_ENVto"production"at build time and is minified. This means all development-only code paths (detailed error messages,kindOfrich type names, unexpected state key warnings) are completely stripped. The standard ESM and CJS builds leave theprocess.env.NODE_ENVchecks intact for the consuming bundler to handle.
Core Architectural Insight: Closures, Not Classes
If you've used Redux, you might assume the store is an instance of a class. It's not. createStore is a function that returns a plain object — and the "private state" of the store lives in closure variables, not on this:
Six let declarations. That's the entire private state of a Redux store:
flowchart TD
subgraph "Closure Scope (private)"
CR[currentReducer]
CST[currentState]
CL[currentListeners]
NL[nextListeners]
LIC[listenerIdCounter]
ID[isDispatching]
end
subgraph "Returned Object (public)"
D["dispatch()"]
S["subscribe()"]
G["getState()"]
R["replaceReducer()"]
O["[Symbol.observable]()"]
end
D --> CR
D --> CST
D --> ID
D --> CL
D --> NL
G --> CST
S --> NL
S --> LIC
R --> CR
The returned object's methods — dispatch, subscribe, getState, replaceReducer, and the observable accessor — close over these variables. There is no prototype chain, no this binding to worry about, and no way for external code to reach the private state except through the defined interface.
This design choice has concrete consequences:
- No
thisbinding issues. You can destructureconst { dispatch, getState } = storeand the methods still work, because they reference closure variables, notthis. - True encapsulation. Unlike private class fields (which can be inspected in devtools or bypassed with hacks), closure variables are genuinely inaccessible.
- Enhancers compose cleanly. Since the store is a plain object, an enhancer can spread it and override individual methods without subclassing:
{ ...store, dispatch: enhancedDispatch }.
This pattern — returning a plain object with methods closed over mutable variables — is the single most important design decision in Redux. It enables the entire enhancer and middleware system, which we'll explore starting in the next article.
What's Next
We've mapped the territory: 17 files, 9 exports, a layered type system, a multi-format build, and a closure-based architecture. In the next article, we'll go deep into the heart of the library — createStore.ts — and trace every line of the 500-line function that makes Redux work: the argument shuffling, the dispatch cycle, the dual-Map listener snapshotting pattern, and the enhancer short-circuit that makes middleware possible.