In a Redux Toolkit case reducer, why may you either mutate state or return a new value, but never both?
answer
- the state argument is a draft
- two ways to state the result
- an arrow function returns something
- reassigning the parameter changes nothing
basics
~20 sRedux Toolkit runs case reducers through Immer, which treats either the recorded draft mutations or the returned value as the next state. Mutating the draft and also returning a different value is ambiguous, so Immer throws.
solid answer
~40 s`createSlice` and `createReducer` wrap each case reducer in Immer's `produce`. The `state` you receive is a **draft**: Immer records every write to it and, when the reducer finishes, builds a new immutable state that reuses every untouched branch. The reducer can express the result in exactly one way: **mutate** the draft and return nothing, or **return** a brand-new value and leave the draft alone. If it does both, Immer cannot tell which result you meant and throws. The classic trigger is `(state, action) => state.push(action.payload)`: the arrow implicitly returns `push`'s new length, so it both mutates and returns. Wrap the body in braces or use `void`. The opposite trap is `state = action.payload`, which only rebinds a local variable, so nothing changes; write `return action.payload`.
code
ts · 23 linesimport { createSlice, current, type PayloadAction } from '@reduxjs/toolkit'
const listSlice = createSlice({
name: 'list',
initialState: { items: [] as string[], filter: '' },
reducers: {
// mutate the draft, return nothing
itemAdded(state, action: PayloadAction<string>) {
state.items.push(action.payload)
},
// return a new value, leave the draft alone
listReset() {
return { items: [], filter: '' }
},
// build immutably, save with a write: still one channel
itemRemoved(state, action: PayloadAction<string>) {
state.items = state.items.filter((i) => i !== action.payload)
console.log(current(state))
},
// throws: the arrow returns push's length AND mutates
// broken: (state, action: PayloadAction<string>) => state.items.push(action.payload),
},
})go deeper
Remember the rule as one channel only: write to state and return nothing, or return a new value. Watch for arrow functions that return a mutation's result.
Explain that state is an Immer draft recording writes, that the result is built with structural sharing, and why returning plus mutating is ambiguous enough that Immer throws.
Spot the silent failures in review: rebinding state, mutating a destructured primitive, and primitive slice state that cannot be drafted, and know current() for debugging drafts.
Weigh the readability gain of draft-based reducers against the hidden protocol it adds, and how a team documents and lints that protocol when onboarding engineers new to RTK.
## What the state argument really is Redux requires reducers to produce a **new** state object instead of changing the old one, because React-Redux and the DevTools compare references to find out what changed. Redux Toolkit keeps that rule but lets you write code that looks mutating. `createSlice` and `createReducer` call each case reducer inside Immer's `produce` (re-exported by RTK as `createNextState`). - The `state` parameter is a **draft**: a Proxy-wrapped stand-in for the current state. - Every write to the draft, such as `state.items.push(x)` or `todo.done = true`, is **recorded**, not applied to the real state. - When the case reducer returns, Immer builds the next state by copying only the objects on the path to each change and reusing every untouched branch by reference (**structural sharing**). How the Proxy intercepts property writes is a JavaScript language topic; what matters here is the contract Immer imposes on the reducer body. ## The two legal ways to express the result A case reducer tells Immer the next state in exactly one of two ways: 1. **Mutate the draft and return nothing** (`undefined`). Immer finalises the recorded changes. 2. **Return a new value and do not touch the draft.** Immer uses the returned value as the next state, which is how you replace the whole slice state, for example `return action.payload` or `return initialState`. Mixing is fine *across* statements when the result still goes through one channel: `state.todos = state.todos.filter(...)` builds a new array immutably and then saves it with a write, so the reducer still returns nothing. What is not allowed is to write to the draft **and** return some other value. Immer then holds two candidate results and refuses to guess, so it throws an error. ## The three bugs this rule explains | Code in the case reducer | What happens | Fix | |---|---|---| | `(state, action) => state.push(action.payload)` | the arrow returns `push`'s new length while also mutating, so Immer throws | `{ state.push(action.payload) }` or `void state.push(action.payload)` | | `state = action.payload` | rebinds the local parameter only, so the state is unchanged | `return action.payload` | | `let { completed } = todo; completed = !completed` | a destructured primitive is a copy, not part of the draft, so nothing is recorded | `todo.completed = !todo.completed` | The first throws loudly; the second and third fail **silently**, which makes them the ones worth recognising in code review. ## Primitive slice state is never drafted Immer can only draft objects and arrays. If a slice's `initialState` is a primitive — `0`, `'idle'`, `false` — there is nothing to wrap, so RTK passes the plain value in and uses whatever you return. - `increment: (state) => state + 1` is correct. - `increment: (state) => { state += 1 }` returns `undefined`, and RTK's `createReducer` throws because a case reducer on a non-draftable value must not return `undefined`. This is why many counter examples keep `{ value: 0 }` as the state: an object can be drafted and written to. ## Working with drafts day to day - **Logging**: `console.log(state)` prints a Proxy that is hard to read. RTK re-exports Immer's `current(state)`, which returns a plain snapshot of the draft at that moment. - **Original values**: `original(state)` (also re-exported) gives the pre-reducer value, useful for comparisons. - **Inserted objects are not drafts**: an object you push into the draft is plain data, not a Proxy, until the next reducer run; that rarely matters but surprises people who inspect it. - **Returning the draft itself** is allowed but pointless; returning a *different* value after writing is the error. - **Structural sharing pays off downstream**: untouched branches keep their references, so a component that selects `state.filter` sees the same value when only `state.items` changed. ## Why the rule is worth defending The payoff of the draft model is that reducers stay short and readable while the store still receives a new immutable object, so reference-based change detection keeps working. The cost is that the reducer body now has a protocol: one way to report the result. Interviewers ask about it because the arrow-function version is the most common first bug in RTK code, and because a candidate who explains *why* Immer throws has understood that the draft is not the real state.
- A Redux Toolkit case reducer must replace the entire slice state with data from an action. What do you write?`return action.payload`. Returning a value is the second legal channel and replaces the state wholesale. Assigning `state = action.payload` only rebinds the parameter, so Immer sees neither a mutation nor a return value and the state stays as it was.
- How do you see what a Redux Toolkit draft looks like midway through a case reducer?Call `current(state)`, re-exported from `@reduxjs/toolkit`. It returns a plain snapshot of the draft including the writes made so far. Logging `state` directly shows the Proxy wrapper, which browsers display in an unhelpful form.
saying these in an interview costs you the question
- Immer ignores the return value when the reducer also mutated state
- Assigning state = newValue replaces the slice state
- RTK reducers mutate the real store state in place
- A counter slice with initialState 0 can use state += 1
- Returning a new object and writing to the draft is just redundant