Skip to the content.

combineReducers splits the store without splitting the truth

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.

combineReducers routes each slice of state to its own reducer One action fans out to user, cart and ui reducers; each receives only its own slice of state and returns its own new slice, which combine into one new state tree. action user(state.user) cart(state.cart) ui(state.ui) { user, cart, ui }one new state tree
Each reducer owns one key of the tree and is handed only that key's value; combineReducers reassembles their outputs into the single new state.

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.