Redux Toolkit 06 ⚛️ Immer Integration — Writing mutating logic safely
Redux Toolkit includes Immer as a built-in dependency, and createSlice uses it internally. When a reducer is defined inside the reducers object, the state argument is not the plain state object; it is an Immer draft. Assignments to the draft—state.value += 1, state.items.push(item), state.user.name = 'Ada'—are recorded and converted into an immutable update when the reducer returns. The previous chapters used this syntax without explaining it; this chapter explains the mechanism, the rules that govern it, and the boundaries where it does not apply.
The integration is not limited to createSlice. The createReducer function and the createAction/extraReducers builder also use Immer, and the caseReducers in createSlice can return a new value instead of mutating the draft. The distinction between mutating and returning is the most common source of errors, and it is the first thing this chapter establishes.
Key point: Inside a reducer created by createSlice or createReducer, the state argument is an Immer draft. Mutating the draft is the intended usage, and returning undefined after mutation is correct. Returning a new value is also correct, but the reducer must not do both. A reducer that mutates the draft and then returns a value produces an Immer error, and a reducer that returns a value after mutating a nested field silently discards the mutation.
Why Immer is built into Redux Toolkit
The spread-chain problem.
A classic Redux reducer for a nested update requires a spread at every level. Updating a todo’s nested metadata inside an array of todos is a .map() with a spread on the matching element and another spread on the nested object. At four levels, the code becomes unreadable, and a missing spread replaces the subtree instead of updating it. Immer replaces the spreads with direct assignment: state.todos.find(t => t.id === id).meta.priority = 'high'. The mutation is recorded, and the immutable result is produced by the library .
The accidental-mutation problem.
A classic Redux reducer that mutates the state directly does not notify the subscribers, because the state reference is unchanged. The UI does not update, and the bug is difficult to trace. Redux Toolkit’s configureStore adds a development-time middleware that detects accidental mutations and logs a warning. The Immer draft makes the mutation safe inside the reducer, and the middleware catches the mutations that happen outside it. The combination gives the developer the convenience of mutation syntax with the safety of immutable updates .
The serialization problem.
Redux Toolkit’s default middleware includes a serializability check that warns when a non-serializable value is stored in the state or dispatched in an action. The Immer draft is a proxy, not a plain object, and it is never stored in the state—Immer produces a plain object from the draft when the reducer returns. The draft is internal to the reducer, and the state that reaches the store is serializable .
a. The mutating syntax and its rules
A reducer inside createSlice receives the draft as its first argument. The reducer can mutate the draft or return a new value, but not both.
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
interface Todo {
id: number
text: string
meta: { priority: 'low' | 'high'; tags: string[] }
}
interface TodoState {
todos: Todo[]
filter: 'all' | 'active'
}
const todoSlice = createSlice({
name: 'todos',
initialState: { todos: [], filter: 'all' } as TodoState,
reducers: {
// Mutate the draft: push to an array
addTodo: (state, action: PayloadAction<string>) => {
state.todos.push({ id: Date.now(), text: action.payload, meta: { priority: 'low', tags: [] } })
},
// Mutate a nested field: find and assign
setPriority: (state, action: PayloadAction<{ id: number; priority: 'low' | 'high' }>) => {
const todo = state.todos.find((t) => t.id === action.payload.id)
if (todo) todo.meta.priority = action.payload.priority
},
// Mutate an array inside an object
addTag: (state, action: PayloadAction<{ id: number; tag: string }>) => {
const todo = state.todos.find((t) => t.id === action.payload.id)
if (todo) todo.meta.tags.push(action.payload.tag)
},
// Return a new value instead of mutating
clearTodos: (state) => {
return { ...state, todos: [] }
},
},
})
The addTodo action pushes to the draft’s todos array. The setPriority action finds the todo in the draft and assigns the priority. The addTag action pushes to the nested tags array. All three mutate the draft and return undefined, which is the intended usage. The clearTodos action returns a new object instead of mutating, which is also valid because it does not touch the draft .
The reducer must not do both. The following reducer produces an Immer error because it mutates the draft and returns a value.
// ❌ INCORRECT: mutating and returning
broken: (state) => {
state.filter = 'all'
return { ...state, todos: [] } // Immer error
}
The correct form is either to mutate the draft and return nothing, or to return a new value without touching the draft. The two styles can be mixed across different reducers in the same slice, but each individual reducer must choose one.
b. Immer in createReducer and extraReducers
The createReducer function uses Immer the same way createSlice does. The builder callback form is the recommended syntax because it provides better TypeScript inference than the object form.
import { createReducer } from '@reduxjs/toolkit'
import { increment, decrement } from './counterActions'
const counterReducer = createReducer(0, (builder) => {
builder
.addCase(increment, (state) => {
// Immer draft: return a new number or mutate
return state + 1
})
.addCase(decrement, (state) => {
return state - 1
})
})
The createReducer builder handles the action types and provides the draft to the case reducer. For a primitive state like a number, the reducer returns a new value; for an object or array, it can mutate the draft. The createReducer function is used when the reducers are defined outside a slice, such as in a standalone reducer module .
The extraReducers builder in createSlice also uses Immer. The case reducers for the pending, fulfilled, and rejected actions of a createAsyncThunk receive the draft and can mutate it.
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true // Mutate the draft
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false
state.data = action.payload
})
.addCase(fetchUser.rejected, (state) => {
state.loading = false
})
},
})
The pending, fulfilled, and rejected handlers mutate the draft directly. The mutations are recorded, and the immutable state is produced when the reducer returns. The same rules apply: mutate or return, not both .
c. The boundaries of Immer
Immer applies inside the reducers that Redux Toolkit creates. It does not apply to the state outside the reducers, to the action payloads, or to the values returned by selectors.
// ❌ INCORRECT: mutating state outside a reducer
store.getState().todos.push(newTodo) // No re-render
// ❌ INCORRECT: mutating a selector result
const todos = useSelector((state) => state.todos)
todos.push(newTodo) // Mutates the state
// ❌ INCORRECT: mutating an action payload
dispatch(addTodo(todo))
todo.text = 'changed' // The payload is not a draft
Outside a reducer, the state is a plain immutable object. Mutating it directly does not notify the subscribers, and the component does not re-render. The development-time mutation check in configureStore logs a warning when it detects this, but the warning is not a substitute for the correct pattern: dispatch an action, and let the reducer update the state.
The draft is also not available in prepare callbacks. A prepare callback runs before the reducer and returns the action payload; it does not receive the state. The prepare callback can generate an ID or a timestamp, but it cannot mutate the state .
addTodo: {
prepare(text: string) {
return { payload: { id: nanoid(), text, createdAt: Date.now() } }
},
reducer(state, action: PayloadAction<{ id: string; text: string; createdAt: number }>) {
state.items.push(action.payload) // Mutation is safe here
},
}
The prepare callback returns the payload, and the reducer receives the draft and mutates it. The two functions are separate, and Immer applies only to the reducer.
Complete Example Session
// ============================================
// PART 1: THE STATE SHAPE
// ============================================
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
interface Comment { id: number; text: string; likes: number }
interface Post {
id: number
title: string
comments: Comment[]
meta: { published: boolean; tags: string[] }
}
interface PostsState {
posts: Post[]
selectedId: number | null
}
// ============================================
// PART 2: MUTATING REDUCERS
// ============================================
const postsSlice = createSlice({
name: 'posts',
initialState: { posts: [], selectedId: null } as PostsState,
reducers: {
// ============================================
// PART 3: ARRAY PUSH
// ============================================
addPost: (state, action: PayloadAction<{ id: number; title: string }>) => {
state.posts.push({
id: action.payload.id,
title: action.payload.title,
comments: [],
meta: { published: false, tags: [] },
})
},
// ============================================
// PART 4: NESTED OBJECT UPDATE
// ============================================
publishPost: (state, action: PayloadAction<number>) => {
const post = state.posts.find((p) => p.id === action.payload)
if (post) post.meta.published = true
},
// ============================================
// PART 5: NESTED ARRAY PUSH
// ============================================
addComment: (state, action: PayloadAction<{ postId: number; text: string }>) => {
const post = state.posts.find((p) => p.id === action.payload.postId)
if (post) {
post.comments.push({ id: Date.now(), text: action.payload.text, likes: 0 })
}
},
// ============================================
// PART 6: ARRAY ELEMENT UPDATE
// ============================================
likeComment: (state, action: PayloadAction<{ postId: number; commentId: number }>) => {
const post = state.posts.find((p) => p.id === action.payload.postId)
const comment = post?.comments.find((c) => c.id === action.payload.commentId)
if (comment) comment.likes += 1
},
// ============================================
// PART 7: ARRAY FILTER WITH RETURN
// ============================================
deletePost: (state, action: PayloadAction<number>) => {
return {
...state,
posts: state.posts.filter((p) => p.id !== action.payload),
}
},
// ============================================
// PART 8: SIMPLE FIELD UPDATE
// ============================================
selectPost: (state, action: PayloadAction<number | null>) => {
state.selectedId = action.payload
},
},
})
export const {
addPost,
publishPost,
addComment,
likeComment,
deletePost,
selectPost,
} = postsSlice.actions
export default postsSlice.reducer
The eight parts demonstrate the Immer syntax across the common update shapes: array push, nested object assignment, nested array push, array element update, array filter with return, and simple field assignment. The deletePost action is the only one that returns a new value; the rest mutate the draft.
Quick Reference
Mutating Operations
| Operation | Syntax |
|---|---|
| Field assignment | state.value = x |
| Nested field | state.user.name = 'Ada' |
| Array push | state.items.push(item) |
| Array splice | state.items.splice(i, 1) |
| Array element | state.items[i].done = true |
| Find and update | state.items.find(...).field = x |
| Delete from object | delete state.items[id] |
Rules
| Rule | Status |
|---|---|
| Mutate the draft, return nothing | ✅ Correct |
| Return a new value, no mutation | ✅ Correct |
| Mutate and return a value | ❌ Immer error |
| Mutate state outside a reducer | ❌ No re-render |
Mutate a prepare callback’s payload | ❌ Not a draft |
Where Immer Applies
| Context | Immer Draft |
|---|---|
createSlice reducers | Yes |
createSlice extraReducers | Yes |
createReducer builder | Yes |
prepare callback | No |
| Selectors | No |
| Action payloads | No |
Best Practices
✅ Do This:
// Mutate the draft directly
state.todos.push(newTodo) // ✅
// Find and assign
const todo = state.todos.find((t) => t.id === id)
if (todo) todo.done = true // ✅
// Return a new value when replacing the state
return { ...state, items: [] } // ✅
// Use Immer in extraReducers
.addCase(fetchUser.fulfilled, (state, action) => {
state.data = action.payload
}) // ✅
// Use prepare for payload generation, reducer for state
prepare(text) { return { payload: { id: nanoid(), text } } } // ✅
❌ Don’t Do This:
// Mutate and return
state.count += 1; return { count: state.count } // ❌
// Mutate state outside a reducer
store.getState().todos.push(newTodo) // ❌
// Mutate a selector result
const todos = useSelector((s) => s.todos); todos.push(x) // ❌
// Mutate the payload in prepare
prepare(text) { const p = { text }; p.id = 1; return { payload: p } } // ✅ (this is fine)
// But mutating a payload after dispatch is not:
const payload = { text: 'x' }; dispatch(add(payload)); payload.text = 'y' // ❌
// Assume Immer applies to the whole store
// It applies only inside the reducers # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Immer error on reducer | Mutated the draft and returned a value | Choose one style |
| UI does not update | Mutated state outside a reducer | Dispatch an action |
| Nested update lost | Returned a new object after mutating | Do not mix mutation and return |
prepare cannot see state | It runs before the reducer | Generate the payload; mutate in the reducer |
| Draft stored in state | Returned the draft instead of a new value | Return a plain object |
Real-World Examples
1. Array push
state.items.push(newItem)
2. Nested field
state.user.preferences.theme = 'dark'
3. Find and update
const item = state.items.find((i) => i.id === id)
if (item) item.done = true
4. Nested array push
state.posts.find((p) => p.id === id).comments.push(comment)
5. Array filter with return
return { ...state, items: state.items.filter((i) => i.id !== id) }
6. Delete from object
delete state.itemsById[id]
7. Simple field
state.selectedId = id
8. extraReducers mutation
.addCase(fetchUser.fulfilled, (state, action) => { state.data = action.payload })
9. prepare callback
prepare(text) { return { payload: { id: nanoid(), text } } }
10. Reset with return
return initialState
Visual
The Immer Draft
┌──────────────────────────────────────────────────────────────┐
│ THE REDUCER RECEIVES A DRAFT, NOT THE PLAIN STATE │
│ │
│ Current state (immutable): │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ { todos: [ { id: 1, done: false } ] } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ createSlice creates a draft: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ draft.todos[0].done = true │ │
│ │ → mutation recorded │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Immer produces a new state: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ { todos: [ { id: 1, done: true } ] } │ │
│ │ ↑ new array ↑ new todo │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Unchanged branches retain their references. │
└──────────────────────────────────────────────────────────────┘
Mutate or Return, Not Both
┌──────────────────────────────────────────────────────────────┐
│ TWO VALID STYLES, ONE INVALID COMBINATION │
│ │
│ ✅ Mutate: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ (state) => { │ │
│ │ state.count += 1 │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ✅ Return: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ (state) => { │ │
│ │ return { ...state, count: state.count + 1 } │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ❌ Mutate and return: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ (state) => { │ │
│ │ state.count += 1 │ │
│ │ return { ...state, count: state.count } │ │
│ │ } │ │
│ │ → Immer error │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Where Immer Applies
┌──────────────────────────────────────────────────────────────┐
│ IMMER APPLIES ONLY INSIDE REDUCERS │
│ │
│ ✅ Inside createSlice reducers │
│ ✅ Inside extraReducers │
│ ✅ Inside createReducer builder │
│ │
│ ❌ Outside a reducer (selectors, components) │
│ ❌ In prepare callbacks │
│ ❌ In action payloads after dispatch │
│ │
│ Outside a reducer, the state is a plain immutable object. │
│ Mutating it does not notify subscribers. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Immer dependency | Built into Redux Toolkit |
| Applies to | createSlice, createReducer, extraReducers |
| State argument | Immer draft |
| Mutating syntax | state.value = x, state.items.push(x) |
| Return value after mutation | undefined (correct) |
| Returning a new value | Also correct, if no mutation |
| Mutate and return | Immer error |
| Outside a reducer | State is immutable; mutate via dispatch |
prepare callback | No draft; generates payload only |
| Serializability | Immer produces plain objects |
Key takeaways:
- Redux Toolkit includes Immer, and
createSliceuses it internally. Thestateargument inside a reducer is a draft, and mutating it is the intended usage. The mutation is recorded, and the immutable state is produced when the reducer returns . - Mutate or return, not both. A reducer that mutates the draft and returns a value produces an Immer error. The two styles are both valid individually, but they cannot be combined in the same reducer.
- Immer applies only inside the reducers that Redux Toolkit creates. Outside a reducer, the state is a plain immutable object, and mutating it does not notify subscribers. The development-time middleware logs a warning when it detects an accidental mutation .
- The
preparecallback does not receive a draft. It runs before the reducer and returns the action payload. ID generation and timestamping belong there; state mutation belongs in the reducer . - Immer preserves structural sharing. Unchanged branches retain their references, so selectors that watch them do not re-render. This is the same behavior as the manual spread pattern, produced by the library instead of the developer.
- The draft is not stored in the state. Immer produces a plain object when the reducer returns, so the state that reaches the store is serializable and inspectable.
Remember: Immer is the mechanism that lets Redux Toolkit reducers look like mutation while producing immutable updates. The draft records the changes, and the library produces a new state tree with only the changed branches replaced. Mutate the draft or return a new value, but not both. Outside a reducer, the state is immutable, and mutations must go through dispatch. The prepare callback generates the payload; the reducer mutates the draft. The result is the same immutable state that the manual spread pattern produces, written in a syntax that is easier to read at any depth of nesting.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!