DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/Redux Ecosystem/Actions & Reducers
beginner20 min read·Updated Jun 2025

Actions & Reducers

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.

reduxactionsreducerscombineReducersstate-management

Knowledge Check

11 questions · pass at 70%

0/11
easymcq

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.

Related topics

StorecreateSliceMiddlewarePro

On this page

20m
Knowledge CheckInterview QuestionsCheatsheet

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,





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 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 +











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








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 nothing

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.


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 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.

Performance Tips#

  • 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.

user: userReducer,
});
const store = createStore(rootReducer);
store.dispatch({ type: "counter/incremented" });
// store.getState() → { counter: { value: 1 }, user: { name: null } }
1
}
:
state;
}
const rootReducer = combineReducers({ sliceA: reducerA, sliceB: reducerB });
const store = createStore(rootReducer);
const before = store.getState();
store.dispatch({ type: "a/inc" });
const after = store.getState();
console.log(after === before); // top-level state object
console.log(after.sliceA === before.sliceA); // the slice that changed
console.log(after.sliceB === before.sliceB); // the slice that didn't
=
reducerMap[key];
const previousSliceState = state[key];
const nextSliceState = reducer(previousSliceState, action); // every reducer gets the SAME action
nextState[key] = nextSliceState;
hasChanged = hasChanged || nextSliceState !== previousSliceState;
}
return hasChanged ? nextState : state; // if NOTHING changed, return the ORIGINAL top-level state too
};
}