Write Mutable Redux Reducers Safely with createSlice and Immer
Redux Toolkit’s createSlice uses Immer to let you mutate a draft state while the store still receives immutable updates.
21 Jun 2026, 04:01 UTC

Direct state mutation breaks Redux assumptions
In plain Redux, reducers must return a new state object on every dispatch. If you push to an array or assign a property directly inside a reducer, the store holds a reference to the same object it had before. Components that rely on referential equality may not re-render, and Redux DevTools time-travel debugging can show confusing or missing diffs.
What createSlice does
Redux Toolkit’s createSlice generates action creators and action type constants from reducer names. Inside each reducer, Immer provides a draft state—a proxy object that looks and behaves like the real state. You can write code that mutates the draft, such as draft.items.push(action.payload). When the reducer finishes, Immer finalizes the draft into a new immutable state object. The original state reference stays untouched.
Immer proxies only apply inside reducer functions. Mutations attempted in components, selectors, or middleware bypass the immutable update guarantee.
Worked example: a todo slice
Run these commands in your project’s root directory. No special permissions are required beyond normal file write access.
npm install @reduxjs/toolkit
Create the slice file:
// todosSlice.js
import { createSlice } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [], filter: 'all' },
reducers: {
addTodo(state, action) {
// state is an Immer draft inside the reducer
state.items.push({ id: Date.now(), text: action.payload, done: false });
},
toggleTodo(state, action) {
const todo = state.items.find((item) => item.id === action.payload);
if (todo) {
todo.done = !todo.done;
}
},
},
});
export const { addTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer;
To verify the behavior, dispatch addTodo and compare the state reference before and after:
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './todosSlice';
const store = configureStore({ reducer: { todos: todosReducer } });
const before = store.getState().todos;
store.dispatch(addTodo('Learn Immer'));
const after = store.getState().todos;
// Assertions:
// before === after should be false
// before.items.length should remain unchanged
// after.items.length should include the new item
Open Redux DevTools, dispatch addTodo, and confirm the state diff shows a new items array rather than an in-place mutation of the previous array.
This example targets @reduxjs/toolkit 1.x or 2.x. RTK 2.0 removed some older browser targets and deprecated exports, but createSlice remains stable across both lines.
Limitations and where Immer does not help
- Never return or store the Immer draft itself. Only the finalized state returned by the reducer enters the Redux store.
- Reducers must remain synchronous. Async data fetching belongs in
createAsyncThunkor middleware, not insidecreateSlicereducers. - Selectors must not mutate state. Direct mutation outside a reducer breaks referential equality checks and time-travel debugging.
Trade-off
Mutable-looking syntax can hide the fact that Immer is copying and freezing state under the hood. For very large or deeply nested state trees, that copying has a measurable cost. Profile your reducers if state updates become a bottleneck.
Check the result
- Install
@reduxjs/toolkitif you have not already:npm install @reduxjs/toolkit - Create the slice and reducer as shown above.
- Dispatch a slice action and assert that the pre-dispatch state reference differs from the post-dispatch reference.
- Inspect Redux DevTools to confirm the diff represents a replacement, not a mutation.
createSlice removes much of the manual action-object boilerplate in plain Redux while keeping Redux’s core guarantee: every state transition produces a new, immutable state object.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.