How does Redux's combineReducers compute the next state on each dispatch, and when does it return the previous root object unchanged?
answer
- one key per slice reducer
- every reducer sees every action
- compare each slice by reference
- no slice changed, old root back
basics
~10 scombineReducers calls every slice reducer with its own slice and the same action, collects the results under the same keys, and returns the previous root object when every slice returned its previous reference.
solid answer
~40 s`combineReducers({ todos, filters })` returns one root reducer. On every dispatch it loops over its keys, calls each slice reducer with `state[key]` and the **same** action, and puts the result under that key in a new object. It tracks whether any slice returned a different reference, or whether the number of keys differs; if nothing changed it returns the **old** root object, otherwise the new one. So every slice reducer runs for every action, and one action can update several slices. Each slice reducer only sees its own slice, not its siblings'. If any slice returns `undefined`, it throws, naming the key and the action type. Keys become state keys, so the object you pass defines the top-level shape of the state tree.
code
ts · 19 linesimport { combineReducers, type UnknownAction } from 'redux'
const todos = (state: string[] = [], action: UnknownAction) =>
action.type === 'todos/todoAdded' ? [...state, action.payload as string] : state
const filters = (state = { visibility: 'all' }, action: UnknownAction) =>
action.type === 'filters/visibilityChanged'
? { visibility: action.payload as string }
: state
const root = combineReducers({ todos, filters })
const s0 = root(undefined, { type: 'app/started' })
const s1 = root(s0, { type: 'analytics/pageViewed' }) // no slice changed
const s2 = root(s1, { type: 'todos/todoAdded', payload: 'Ship it' })
console.log(s1 === s0) // true: previous root returned
console.log(s2 === s1) // false: todos changed
console.log(s2.filters === s1.filters) // true: untouched slice keptgo deeper
Know that combineReducers turns an object of slice reducers into one root reducer, and that the keys become the state's top-level keys.
Walk through the loop: every slice runs for every action, results are compared by reference, and the previous root comes back when nothing changed.
Handle the edges: cross-slice updates, slices returning copies for ignored actions, and when a hand-written root reducer beats combineReducers.
Discuss state-tree organization at scale: domain-based slices, ownership boundaries between teams, and keeping the root composition understandable.
## The problem it solves A real app has many independent areas of state, such as the signed-in user, a todo list and UI filters. Writing one giant reducer for all of them is unmanageable. Redux's answer is **reducer composition**: each area gets its own **slice reducer**, and a helper combines them into the single root reducer the store needs. `combineReducers` is that helper for the most common case, where slices are independent keys of one object. ```ts const rootReducer = combineReducers({ user, todos, filters }) // state shape: { user: ..., todos: ..., filters: ... } ``` ## What happens on each dispatch For every action the store dispatches, the combined reducer: 1. Loops over its **own keys**, the ones you passed, in `Object.keys` order. 2. For each key, calls `sliceReducer(state[key], action)`, passing **only that slice** and the **same action**. 3. Throws if a slice returns `undefined`, naming the key and the action type. 4. Writes the result into a new object under the same key. 5. Tracks `hasChanged`: true if any slice's result `!==` its previous value. 6. Also marks a change if the incoming state has a different number of keys than the reducer map. 7. Returns the **new object if anything changed**, otherwise the **original state object**. ## Consequences worth saying in an interview - **Every slice reducer runs for every action.** There is no routing by action type. That is why each slice must return its state unchanged for actions it ignores, and why a single event-style action can update several slices at once. - **Reference preservation is built in.** If no slice changed, the root reference does not change either, so any code comparing root state by `===` sees "no change". This only works if the slices themselves return their existing state for ignored actions. - **Slices are isolated.** A slice reducer receives `state[key]`, not the root. It cannot read a sibling slice. If an update needs another slice's data, either put that data in the action payload or write a custom root reducer that passes it in explicitly. - **Shape is defined by keys.** The object you pass is the state's top level. Nesting `combineReducers` calls nests the state. ## Development-time checks | Situation | What `combineReducers` does | |---|---| | A key's value is `undefined` in the map | Warns "No reducer provided for key" in development and ignores the key | | A slice returns `undefined` during its probes | The combined reducer throws on its first call | | A slice returns `undefined` for a real action | Throws, naming the key and action type | | Incoming state has keys with no reducer | Warns about unexpected keys in development, then drops them | | Incoming state is not a plain object | Warns about the unexpected type in development | ## Design choices it pushes you towards - **Organize state by data domain, not by component.** Slice keys like `todos`, `users` and `filters` survive UI redesigns; keys like `sidebarPanel` do not. - **Let reducers own their shape.** Each slice reducer is the only code that knows its structure, which keeps refactors local. - **Name keys after the data.** `combineReducers({ todos: todosReducer })` produces `state.todos`, not `state.todosReducer`. Name the key after what it stores. ## Cost of running every reducer Running every slice reducer on every action sounds expensive, but in practice it rarely is. Each slice typically does one `switch` on `action.type` and returns `state` straight away for actions it does not handle, which is a handful of comparisons. The measurable cost of a dispatch usually sits **downstream**: subscribers rerunning selectors and components re-rendering. That is also where a slice that returns a *copy* for ignored actions hurts, because a new reference, even with identical contents, looks like a change to every `===` check that reads it. ## Nesting `combineReducers` composes recursively. A slice reducer can itself be the result of `combineReducers`, producing nested state: - `combineReducers({ entities: combineReducers({ users, posts }), ui })` gives `state.entities.users`, `state.entities.posts` and `state.ui`. - Each level applies the same rules: every child runs for every action, references are compared, and the previous object is returned when nothing changed. ## When not to use it `combineReducers` is a convenience, not a requirement. When one action must update slice A using data from slice B, a small hand-written root reducer can call `combineReducers` for the independent parts and handle the cross-slice case explicitly. Redux's docs cover this under "beyond combineReducers"; the store only needs *some* function with the reducer signature.
- A slice reducer needs another slice's data to handle an action. What are the options with combineReducers?Put the needed data in the action payload, reading it from state before dispatch, often in a thunk. Or write a custom root reducer that calls the combined reducer for independent slices and then handles the cross-slice action itself, passing both slices in explicitly. Reaching into the store from inside a reducer is not an option: getState throws while a reducer runs.
- Why does combineReducers compare slice references instead of deep-comparing state?Reference comparison is constant-time per slice and works because correct reducers return the same object when nothing changed. A deep comparison would cost time proportional to the size of the state on every dispatch. The design makes the immutable-update convention pay off: cheap checks, if every reducer keeps its side of the deal.
saying these in an interview costs you the question
- combineReducers only calls the reducer whose key matches the action type
- A slice reducer receives the whole root state
- combineReducers always returns a brand-new root object
- combineReducers deep-merges slices, so returning a partial slice keeps the rest
- Slice keys should be named after the reducer function, like todosReducer