Concept
An action is a plain object describing what happened, by convention, a type string plus whatever data the reducer needs to respond:
const incremented = { type: "counter/incremented" };
const userLoaded = { type: "user/loaded", payload: { name: "Ada" } };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:
import { combineReducers, createStore } from "redux";
function userReducer(state = { name: null }, action) {
switch (action.type) {
case "user/loaded":
return { name: action.payload.name };
default:
return state;
}
}
const rootReducer = combineReducers({
counter: counterReducer,
user: userReducer,
});
const store = createStore(rootReducer);
store.dispatch({ type: "counter/incremented" });
// store.getState() → { counter: { value: 1 }, user: { name: null } }store.dispatch({ type: 'counter/incremented' });
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 both counterReducer 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.
Action creators: functions that return actions
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.
Try It
Predict the outcome before checking the solution.
function reducerA(state = { a: 0 }, action) {
return action.type === "a/inc" ? { a: state.a + 1 } : state;
}
function reducerB(state = { b: 0 }, action) {
return action.type === "b/inc" ? { b: state.b + 1 } : state;
}
const rootReducer = combineReducers({ sliceA: reducerA, sliceB: reducerB });
const store = createStore(rootReducer);
const before = store.getState();
What do the three console.log lines print?
Solution
false, false, true.
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.
Implement It Yourself
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 = reducerMap[key];
const previousSliceState = state[key];
const nextSliceState = reducer(previousSliceState, action); // every reducer gets the SAME action
nextState[key] = nextSliceState;
hasChanged = hasChanged
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.
Under the Hood
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.
Common Mistakes
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.
3. Writing action type strings inconsistently
store.dispatch({ type: "INCREMENT" }); // ❌
store.dispatch({ type: "increment" }); // ❌ typo/mismatch, silently matches nothingSince 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.
Best Practices
- 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
stateoractioninside 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
undefinedstate at creation, specifically to resolve this default into real initial state.
Performance Tips
- Returning the same reference from a reducer's
defaultcase isn't just cosmetic, it's what allowsuseSelectorand memoized selectors (reselect, covered next in Selectors & Reselect) to skip unnecessary re-renders and recomputations for slices an action doesn't touch. combineReducersruns every slice reducer on every dispatched action, keep each slice reducer'sdefault-case check cheap (aswitchon a string is effectively free) since it executes on every single action in the app, not just ones relevant to that slice.
