combineReducers: Reducer Composition, Validation, and the Reference Equality Trick
Prerequisites
- ›Articles 1-2
- ›Understanding of how dispatch calls the root reducer
combineReducers: Reducer Composition, Validation, and the Reference Equality Trick
In Article 2, we saw that dispatch calls currentReducer(currentState, action) — a single function that receives the entire state tree and returns the next one. For any non-trivial app, that single function is actually a tree of smaller reducers composed together by combineReducers. This is where Redux's "composition over configuration" philosophy becomes concrete.
combineReducers does two things exceptionally well: it validates aggressively at creation time so errors surface immediately, and it optimizes the hot path with a reference equality check that avoids allocating new state objects when nothing changed. Let's see how.
Creation-Time Validation: assertReducerShape
When you call combineReducers({ todos: todosReducer, filters: filtersReducer }), the resulting reducer isn't returned immediately. First, Redux probes every slice reducer to verify it behaves correctly:
src/combineReducers.ts#L62-L94
flowchart TD
A["assertReducerShape(reducers)"] --> B["For each reducer..."]
B --> C["Call reducer(undefined, { type: ActionTypes.INIT })"]
C --> D{"Returns undefined?"}
D -- Yes --> E["throw Error:<br/>'returned undefined during initialization'"]
D -- No --> F["Call reducer(undefined, { type: PROBE_UNKNOWN_ACTION() })"]
F --> G{"Returns undefined?"}
G -- Yes --> H["throw Error:<br/>'returned undefined for unknown action'"]
G -- No --> I["✓ Reducer is valid"]
The two-probe strategy catches distinct mistakes:
-
INIT probe: If
reducer(undefined, INIT)returnsundefined, the reducer doesn't have a default state. This catches the classic "forgot to add adefaultcase with initial state" bug. -
PROBE_UNKNOWN_ACTION probe: This fires a random action type (remember
PROBE_UNKNOWN_ACTION()from Article 2 — it's a function that generates a fresh random string each call). If the reducer returnsundefinedfor an unknown action, it means the reducer is either explicitly handling@@redux/INITand returningundefinedfor everything else, or it's using a catch-all that returnsundefined.
Why a random string? Because if the probe used a fixed string, a mischievous reducer could match it and pass the test while still failing for truly unknown actions.
Tip: These probes only run once, at combineReducers call time. They catch configuration errors before any user action is dispatched. If validation throws, the error is captured and re-thrown on every subsequent dispatch, so even lazy initialization surfaces the problem immediately.
The Combined Reducer Function
After validation, combineReducers returns the combination function — the actual reducer that gets called on every dispatch:
src/combineReducers.ts#L157-L201
The runtime logic is a straightforward loop:
flowchart TD
A["combination(state, action)"] --> B["hasChanged = false"]
B --> C["For each key in finalReducerKeys"]
C --> D["previousStateForKey = state[key]"]
D --> E["nextStateForKey = reducer(previousStateForKey, action)"]
E --> F{"nextStateForKey === undefined?"}
F -- Yes --> G["throw Error"]
F -- No --> H["nextState[key] = nextStateForKey"]
H --> I["hasChanged = hasChanged OR<br/>nextStateForKey !== previousStateForKey"]
I --> C
C -- Done --> J{"hasChanged OR<br/>key count changed?"}
J -- Yes --> K["return nextState"]
J -- No --> L["return state ← same reference!"]
The reference equality trick is on line 195-199:
src/combineReducers.ts#L195-L199
If every slice reducer returns its previous state (by === reference comparison), and the number of keys hasn't changed, combineReducers returns the original state object. This is the "nothing changed" fast path.
This matters enormously for React-Redux. When useSelector compares previous and next state with ===, unchanged state means no re-render. If combineReducers always created a new object, every dispatch would re-render every connected component.
There's a subtle detail on lines 197-198: hasChanged also checks if finalReducerKeys.length !== Object.keys(state).length. This catches the case where the state has extra keys not present in the reducer map — which happens when replaceReducer swaps to a reducer with fewer slices.
Dev-Mode Warnings with Caching
In development, combination runs an additional check for unexpected state keys — properties in the state object that don't match any reducer:
src/combineReducers.ts#L14-L60
The unexpectedKeyCache object (line 145-148) ensures each unexpected key is warned about only once. Without this cache, every dispatch would log the same warnings. The cache is scoped to the combineReducers closure, so creating a new combined reducer resets the warnings.
Note the guard on line 50: when the action type is ActionTypes.REPLACE, warnings are suppressed. This is because replaceReducer legitimately causes temporary mismatches between state shape and the new reducer map.
All of this code is wrapped in process.env.NODE_ENV !== 'production' checks, so it's completely eliminated in production builds.
compose: The Functional Primitive
Before we move to applyMiddleware in the next article, we need to understand compose — the 16-line function that makes middleware chains possible:
This is right-to-left function composition: compose(f, g, h) produces (...args) => f(g(h(...args))). The implementation uses Array.reduce to fold the functions together.
flowchart LR
A["compose(f, g, h)"] --> B["reduce: (a, b) => (...args) => a(b(...args))"]
B --> C["Result: (...args) => f(g(h(...args)))"]
subgraph "Execution order"
H["h(...args)"] --> G["g(result)"] --> F["f(result)"]
end
Three special cases:
- Zero arguments: Returns the identity function
(arg) => arg - One argument: Returns the function itself — no wrapping
- Two+ arguments: Uses
reduceto compose right-to-left
The type overloads (lines 13-44) provide precise return types for up to 4 functions, falling back to (...args: any[]) => R for more. This is a common TypeScript pattern when variadic generics can't express the relationship.
compose is how you combine multiple enhancers: createStore(reducer, compose(applyMiddleware(thunk), devTools())). It's also used internally by applyMiddleware to build the middleware chain — which we'll explore in Article 4.
bindActionCreators: Dispatch Binding
The simplest composition utility in Redux, bindActionCreators wraps action creators so they automatically dispatch:
src/bindActionCreators.ts#L58-L83
The implementation handles two forms: a single function (return a single bound function) or an object of functions (return an object of bound functions). The inner helper is four lines:
src/bindActionCreators.ts#L9-L16
It preserves this binding with .apply(this, args) — a detail that matters for class-based components where action creators might reference the component instance. The explicit this: any parameter is TypeScript syntax for declaring the this context type.
Tip:
bindActionCreatorsexists primarily for passing action creators to components that shouldn't know about Redux. In modern React-Redux with hooks,useDispatch+ directdispatch(actionCreator())has largely replaced this pattern. But the function remains useful in non-React contexts where you want to decouple dispatching from action creation.
What's Next
We've covered the two main composition utilities — combineReducers for combining reducers vertically (by state slice) and compose for combining functions horizontally (left-to-right application). In the next article, we'll put compose to work inside applyMiddleware, Redux's most elegant 25 lines of code, and trace exactly how middleware functions are threaded together into a dispatch pipeline.