skip to content

In a Redux Toolkit case reducer, why may you either mutate state or return a new value, but never both?

level: middleimportance: must knowfreq 64%

answer

  1. the state argument is a draft
  2. two ways to state the result
  3. an arrow function returns something
  4. reassigning the parameter changes nothing

basics

~20 s

Redux 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 lines
ts
import { 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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