combineReducers delegates the SAME dispatched action to EVERY slice reducer — each one independently decides whether to respond, confirmed by dispatching an action that only one of two reducers recognizes and watching the other return its exact original state reference, a genuine no-op, unchanged.
1. When an action is dispatched to a store built with combineReducers({ a: reducerA, b: reducerB }), which reducer(s) receive the action?
Interview Questions
2 questions
Cheatsheet
Download-ready reference
Cheatsheet
ACTION = plain object describing WHAT HAPPENED
{ type: "feature/event", payload: ... } ← "feature/event" convention
REDUCER = pure function: (state, action) => newState
- MUST NOT mutate state or action
- MUST return the SAME reference if nothing changed (no-op case)
- MUST have a default parameter for initial state
- MUST have a default case returning state unchanged
combineReducers({ a: reducerA, b: reducerB })
→ EVERY slice reducer receives EVERY dispatched action
→ each decides INDEPENDENTLY whether to respond
→ CONFIRMED: an untouched slice keeps its EXACT reference (===)
→ CONFIRMED: if ALL slices are unchanged, the real combineReducers
returns the ORIGINAL top-level state object too
ACTION CREATOR = function returning an action object
function incremented() { return { type: "counter/incremented" }; }
→ convention, not a Redux requirement
→ auto-generated by Redux Toolkit's createSlice
⚠️ array.push(x) returns the new LENGTH (a number) and mutates —
never use it inside a reducer; use [...arr, x] instead.
⚠️ typo'd type strings silently match nothing — no error, just a
silent no-op via the default case.
A reducer is a pure function: (state, action) => newState. Given the current state and a dispatched action, it returns what the next state should be — and critically, it must not mutate the state it received.
function counterReducer(state = { value: 0 }, action) { switch (action.type) { case "counter/incremented": return { value: state.value + 1 }; case "counter/decremented": return { value: state.value - 1 }; default: return state; // unrecognized action → return the SAME reference, unchanged }}
That default: return state case matters more than it looks — it's what makes a reducer safely ignorable by actions it doesn't care about, which is exactly what makes multiple reducers able to coexist against the same stream of dispatched actions.
combineReducers: one action, many reducers, each deciding independently#
A real app has more than one slice of state, so combineReducers builds a single root reducer out of several smaller ones, each responsible for its own slice:
combineReducers delegates the SAME action to EVERY slice reducer
step 1 / 4
store.dispatch({ type: 'counter/incremented' });
Action
active
{ type: 'counter/incremented' }
→
Root reducer
waiting
about to receive it
dispatch() sends the action to the store's single root reducer — this is the ONLY way state changes in Redux; nothing else can mutate the store.
Confirmed by running this exact setup and dispatching { type: "counter/incremented" }: combineReducers calls bothcounterReducer and userReducer with the identical action object. counterReducer recognizes the type and returns a new { value: 1 }. userReducer doesn't recognize it, falls through to default, and returns its exact same state reference — a genuine no-op for that slice, not a re-computed equal-looking object. The resulting top-level state object is new (since one slice changed), but state.user inside it is still === to what it was before the dispatch.
Why the "same reference on no-op" behavior matters#
This reference-stability is what everything downstream depends on: React-Redux's useSelector (covered in Selectors & Reselect) decides whether to re-render a component by comparing selector output references before and after a dispatch. A userReducer that returned a brand-new { name: null } object every single dispatch — even for actions it doesn't care about — would make any component selecting state.user re-render on every action dispatched anywhere in the app, not just ones that actually touch user data.
Writing action objects by hand at every dispatch site is repetitive and error-prone (typos in type strings are a classic bug). An action creator is just a function that returns an action object:
function incremented() { return { type: "counter/incremented" };}function userLoaded(name) { return { type: "user/loaded", payload: { name } };}store.dispatch(incremented());store.dispatch(userLoaded("Grace"));
This is a convention, not a Redux requirement — but it's universal in practice, and it's exactly what Redux Toolkit's createSlice (covered in createSlice) auto-generates for you from a set of reducer functions.
The top-level state object is always a new object after any dispatch that changes at least one slice (combineReducers constructs a fresh wrapper object each time). sliceA genuinely changed, so its reference is new too. But sliceB's reducer never matched "a/inc", fell through to its implicit "return unchanged" path, and returned the exact same{ b: 0 } reference it was given — confirmed identical by ===. This selective reference-stability, one level per slice, is the entire mechanism that lets a useSelector(s => s.sliceB) correctly skip re-rendering when only sliceA changed.
Build a minimal version of combineReducers, to see exactly how the per-slice delegation and reference-stability work:
function myCombineReducers(reducerMap) { const keys = Object.keys(reducerMap); return function rootReducer(state = {}, action) { let hasChanged = false; const nextState = {}; for (const key of keys) { const reducer
This makes visible a detail the real combineReducers also implements: if literally every slice reducer returns its unchanged reference (e.g. dispatching an action nobody recognizes), the entire root state object stays the same reference too — not just each slice individually. That's one more level of reference-stability the real implementation provides, matching the behavior confirmed in the Try It exercise above.
This directly extends Store's single-dispatch-funnel model — combineReducers is how that one funnel fans out to multiple independently-testable slice reducers without creating multiple stores. The per-slice reference-stability demonstrated here is the exact mechanism Selectors & Reselect relies on to decide when a connected component actually needs to re-render, and it's what createSlice automates (along with action-creator generation) via Redux Toolkit's Immer-powered reducer syntax.
1. Mutating the payload or returning a mutated copy of state#
function reducer(state = { items: [] }, action) { if (action.type === "add") { return { ...state, items: state.items.push(action.payload) }; // ❌ push() mutates AND returns the new length, not the array } return state;}
Array.prototype.push mutates the array in place and returns the new length (a number) — this both breaks the original array's identity guarantees elsewhere in the app and assigns a number where an array was expected. Use [...state.items, action.payload] instead.
2. Forgetting the default case (or a default parameter)#
function reducer(state, action) { // ❌ no default parameter switch (action.type) { case "inc": return { value: state.value + 1 }; } // ❌ no default case — returns undefined for anything else, including Redux's own internal init action}
Without a default parameter, the reducer has no initial state to fall back on when the store first calls it. Without a default case, any unrecognized action (including Redux's internal @@redux/INIT action, dispatched automatically on store creation) makes the reducer return undefined.
Since type is just a string compared with === inside a switch, any typo produces a silent no-op rather than an error — the action falls through to default and nothing happens, with no warning. This is exactly the class of bug action creators (and later, Redux Toolkit's auto-generated action creators) are designed to eliminate.
Namespace action types as "feature/event" (e.g. "counter/incremented", "user/loaded") — avoids collisions between features and makes the dispatched-action log immediately scannable.
Never mutate state or action inside a reducer — always build and return new objects/arrays for anything that changed, and return the exact same reference for anything that didn't.
Always give reducers a default parameter for initial state — the store calls every reducer once with undefined state at creation, specifically to resolve this default into real initial state.
Returning the same reference from a reducer's default case isn't just cosmetic — it's what allows useSelector and memoized selectors (reselect, covered next in Selectors & Reselect) to skip unnecessary re-renders and recomputations for slices an action doesn't touch.
combineReducers runs every slice reducer on every dispatched action — keep each slice reducer's default-case check cheap (a switch on a string is effectively free) since it executes on every single action in the app, not just ones relevant to that slice.