createSlice's 'mutating' reducer syntax (state.counter.value += 1) is confirmed to produce a genuinely immutable update via Immer under the hood — including deep STRUCTURAL SHARING: untouched nested branches (even several levels deep) keep their EXACT original reference, confirmed by direct === checks, while only the actually-changed path gets new objects.
1. When a createSlice reducer writes `state.value += 1`, what actually happens to the ORIGINAL state object?
Interview Questions
2 questions
Cheatsheet
Download-ready reference
Cheatsheet
createSlice({ name, initialState, reducers, extraReducers? })
→ AUTO-GENERATES: reducer function + action creators + action types
(each `reducers` key → case "name/key" + exported action creator)
"MUTATING" SYNTAX INSIDE createSlice IS SAFE — CONFIRMED via Immer:
state.value += 1 → translated into a genuine NEW object
state.items.push(x) → translated into a genuine NEW array
before === after → FALSE (real new reference produced)
CONFIRMED DEEP STRUCTURAL SHARING:
only touch state.counter → state.counter is NEW,
state.user (untouched) is the
EXACT SAME reference, even nested
(state.user.settings === before too)
{ reducer, prepare } FORM — customize action creator payload:
prepare(arg) { return { payload: {...computed} }; }
→ decouples action creator's call signature from payload shape
⚠️ NEVER mutate the draft AND return a new value in the same case
⚠️ `state = x` REASSIGNS THE LOCAL VARIABLE — Immer never sees it,
has ZERO effect. Use `return x;` or mutate draft FIELDS instead.
Structural sharing is WHY createSlice output stays compatible with
useSelector/createSelector's reference-equality optimizations.
createSlice is Redux Toolkit's single biggest boilerplate reduction: it generates a reducer function, matching action creators, and action type strings — all from one object describing a slice's initial state and its reducer cases, written as if you were allowed to mutate state directly.
Compare this to the hand-written equivalent from Actions & Reducers — a switch statement, manually-written case strings, and manually-written action creator functions. createSlice generates all of that from the reducers object's own keys: incremented becomes both a case "counter/incremented" inside the generated reducer and an exported incremented() action creator, confirmed to produce { type: "counter/incremented" } — the name field ("counter") automatically prefixes every action type.
The "mutating" syntax is not actually mutation — confirmed via Immer#
const store = configureStore({ reducer: { counter: counterSlice.reducer } });const before = store.getState().counter;store.dispatch(incremented());const after = store.getState().counter;console.log(before === after); // false — a genuinely NEW objectconsole.log(before.value, after.value); // 0, 1
createSlice's reducer still funnels through the same dispatch → reducer path
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.
createSlice reducers run through Immer, a library that intercepts state.value += 1-style syntax on a special "draft" proxy object and translates it, behind the scenes, into a real, genuinely immutable update — producing a brand-new object rather than actually mutating the original. Confirmed directly: before === after is false, exactly matching the manual immutability discipline required by hand-written reducers in Actions & Reducers — createSlice doesn't relax that rule, it enforces it for you, letting you write code that reads like mutation while Immer guarantees the actual output is a new reference.
Confirmed: deep structural sharing, not just top-level#
Confirmed by running this exact slice and checking references before/after dispatching incremented(): before.counter === after.counter is false (the touched branch is genuinely new), but before.user === after.user is true — and even before.user.settings === after.user.settings is true, two levels deep. Immer doesn't just clone the top-level object; it performs genuine structural sharing, only constructing new objects along the exact path that actually changed, and reusing every untouched branch's original reference — precisely the reference-stability behavior Actions & Reducers established matters for useSelector and createSelector.
Confirmed: the default action-creator shape is (payload) => ({ type, payload }) — a single argument becomes the whole payload. When an action needs a different call signature or a computed/derived payload (like a timestamp), the { reducer, prepare } form lets prepare's return value become the actual dispatched action, decoupling the action creator's call signature from its reducer's payload shape.
Does state.items.push(...) — normally a forbidden mutation per Actions & Reducers — cause a problem here?
Solution
false false 0.
Inside a createSlice reducer, state isn't the real state object — it's an Immer "draft" proxy. Calling .push() on state.items (itself a draft) is intercepted by Immer exactly the same way += was: it's translated into a genuine immutable array update. The originalbefore.items array is completely untouched (still length 0), while after.items is a new array containing the pushed item. This is specifically why array/object mutation methods (.push, .splice, .sort, direct property assignment) are safe to use — and idiomatic — inside createSlice reducers, even though those exact same methods are Essential-tier violations in hand-written, non-Immer reducers.
Sketch a simplified version of what Immer does, to see the actual mechanism createSlice relies on:
function produce(baseState, recipe) { const changes = {}; const draft = new Proxy(baseState, { set(target, key, value) { changes[key] = value; // record the change, don't touch the real object return true; }, get(target,
This drastically simplified version captures the essential trick: a Proxy intercepts what looks like direct property assignment, records it separately instead of touching the real object, and only constructs a new object (based on the recorded changes, not by touching the original) once the recipe function finishes. Real Immer does dramatically more — deep/nested proxies, structural sharing at every level, array method interception — but this is the core mechanism that makes "mutating" syntax produce immutable output.
This directly builds on Actions & Reducers's immutability requirement and Store's reference-comparison-based change detection — createSlice doesn't weaken either rule, it automates compliance with them via Immer. It also connects to configureStore's confirmed finding that its mutation-detection middleware doesn't flag createSlice's syntax, precisely because Immer ensures no actual mutation of the original state object ever occurs.
1. Returning a new value AND mutating the draft in the same reducer case#
reducers: { broken(state, action) { state.value = action.payload; // mutates the draft... return { value: 999 }; // ❌ ...AND returns a new value — Immer throws in this case },}
Immer's rule: either mutate the draft (and return nothing/undefined), or return an entirely new value — never both in the same function. Mixing them is a genuine error Immer will throw on, not silently resolve.
2. Reassigning the top-level state parameter itself#
reducers: { reset(state) { state = initialState; // ❌ reassigns the LOCAL variable, not the actual draft — has NO effect },}
This is a subtle trap: state = initialState only reassigns the function's local parameter binding, it doesn't tell Immer anything about the draft. The fix is either return initialState; (the "return a new value" form) or mutating specific fields of the existing draft (Object.assign(state, initialState)).
3. Assuming Immer's structural sharing means UNCHANGED reducers can be skipped entirely#
// Misconception: "since user.settings is untouched, its reducer case doesn't run at all"
Every case in the reducer that's dispatched still runs (per the confirmed combineReducers-delegates-to-every-reducer behavior from Actions & Reducers) — Immer's structural sharing means the output reference for untouched branches is preserved, not that the code touching them is skipped. The performance win is downstream (in useSelector/createSelector), not in avoiding the reducer call itself.
Use createSlice for all new reducer logic — "mutating" syntax inside it is not just permitted but idiomatic; the Immer safety net makes it both safer and more readable than manual immutable-update spreading.
Use the { reducer, prepare } form whenever an action needs a computed/derived payload or a call signature different from "single argument becomes the whole payload."
Never mix mutating the draft and returning a new value in the same reducer case — pick one per case, consistently.
Immer's structural sharing (confirmed: untouched branches keep their exact reference, even multiple levels deep) is what makes createSlice-generated reducers compatible with useSelector/createSelector's reference-equality optimizations from Selectors & Reselect — without it, every dispatch would produce entirely new references throughout the tree, and nothing could ever skip a re-render or recomputation.
Immer's proxy-based interception has real (if generally small) overhead compared to hand-written direct object spreading — for the overwhelming majority of apps this is negligible next to the boilerplate/bug-reduction payoff; extremely hot-path reducers touching very large collections on every frame are the rare case worth profiling specifically.