combineReducers splits the store without splitting the truth
A single store does not mean a single giant reducer. As an app grows, one
switch statement handling every action for every slice of state becomes
unreadable. combineReducers is the answer: it lets each slice of the state tree
have its own reducer, while the store stays single. The subtle, important
detail is the constraint it imposes — each slice reducer sees only its own
slice of state, never the whole tree. That looks like a limitation and is
actually the feature: it makes every reducer independently understandable and
testable.
Each reducer owns one key and sees only its value
combineReducers takes a map from state key to reducer. When an action is
dispatched, it calls each reducer with that key’s slice, and assembles the
returns into the new tree:
import { combineReducers } from "redux";
const rootReducer = combineReducers({ user, cart, ui });
// on dispatch, internally:
// { user: user(state.user, action),
// cart: cart(state.cart, action),
// ui: ui(state.ui, action) }
The cart reducer receives state.cart, not state. It cannot read the user
slice even if it wanted to — which is exactly why it stays simple and why you can
test it with nothing but a cart-shaped object.
The default case is load-bearing
Because every reducer sees every action, each slice reducer must return its
own state unchanged for actions it does not recognise. That is what the
default: return state line is for — omit it and the slice becomes undefined on
any unrelated action, wiping itself out:
function cart(state = { items: [] }, action) {
switch (action.type) {
case "ITEM_ADDED": return { ...state, items: [...state.items, action.item] };
default: return state; // MUST return the slice untouched
}
}
This is also how one action updates several slices at once: dispatch
USER_LOGGED_IN and the user, cart, and ui reducers each independently
decide their response — no coordination, because each only touches its own key.
When one slice needs another slice’s data
The constraint bites in one place: a reducer that genuinely needs a value from
another slice. The wrong fix is to abandon combineReducers and hand everything
the whole tree. The right fix is usually to derive the cross-slice value with
a selector at read time (selectors can see the whole state), or, for the rare
true case, to pass the needed value in the action so the slice stays ignorant of
its neighbours. Keeping each reducer local is what lets a large store stay a
collection of small, independently-verifiable functions rather than one tangled
switch. The combine-reducers exercise builds exactly this split and the
default-case discipline that keeps it honest.