| | |

Redux Toolkit 06 ⚛️ Immer Integration — Writing mutating logic safely

⏱️ Estimated read: 13 min | |

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

OperationSyntax
Field assignmentstate.value = x
Nested fieldstate.user.name = 'Ada'
Array pushstate.items.push(item)
Array splicestate.items.splice(i, 1)
Array elementstate.items[i].done = true
Find and updatestate.items.find(...).field = x
Delete from objectdelete state.items[id]

Rules

RuleStatus
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

ContextImmer Draft
createSlice reducersYes
createSlice extraReducersYes
createReducer builderYes
prepare callbackNo
SelectorsNo
Action payloadsNo

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

PitfallWhy It HappensFix
Immer error on reducerMutated the draft and returned a valueChoose one style
UI does not updateMutated state outside a reducerDispatch an action
Nested update lostReturned a new object after mutatingDo not mix mutation and return
prepare cannot see stateIt runs before the reducerGenerate the payload; mutate in the reducer
Draft stored in stateReturned the draft instead of a new valueReturn 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

ItemValue
Immer dependencyBuilt into Redux Toolkit
Applies tocreateSlice, createReducer, extraReducers
State argumentImmer draft
Mutating syntaxstate.value = x, state.items.push(x)
Return value after mutationundefined (correct)
Returning a new valueAlso correct, if no mutation
Mutate and returnImmer error
Outside a reducerState is immutable; mutate via dispatch
prepare callbackNo draft; generates payload only
SerializabilityImmer produces plain objects

Key takeaways:

  • Redux Toolkit includes Immer, and createSlice uses it internally. The state argument 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 prepare callback 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.


Was this helpful?


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!