Redux Toolkit's createSlice: Mutable Syntax, Immutable Guarantees
Redux Toolkit's createSlice lets you write mutable-looking reducers that stay immutable under the hood. See a concrete todo example, the trade‑offs, and a quick verification script.
23 Dec 2025, 04:57 UTC

The Problem: Boilerplate Reducers
Classic Redux asks you to write a switch statement for every action type, manually copy the previous state, and return a new object each time. As the number of slices grows, the repetitive case blocks and spread operators become a maintenance burden and a source of subtle bugs.
How createSlice Uses Immer
createSlice (part of Redux Toolkit) generates action creators and action types automatically from the reducer map you provide. Under the hood it wraps each reducer with Immer, a library that lets you write "mutating" code on a draft state while producing a truly immutable result.
When a reducer runs, Immer gives you a draft proxy that mirrors the current state. Assignments like draft.todos.push(action.payload) are recorded, then Immer produces a new state tree that shares unchanged parts with the old one. The Redux store sees only the new reference, preserving the predictable, time‑travel‑friendly semantics Redux is known for.
Worked Example: A Todo Slice
Below is a minimal, self‑contained slice that manages a todo list. It demonstrates the mutable‑looking syntax and shows how the generated actions are used.
// file: src/features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [], filter: 'all' },
reducers: {
addTodo(state, action) {
// Mutating draft – Immer converts this to an immutable update
state.items.push({ id: Date.now(), text: action.payload, completed: false });
},
toggleTodo(state, action) {
const todo = state.items.find(t => t.id === action.payload);
if (todo) todo.completed = !todo.completed;
},
setFilter(state, action) {
state.filter = action.payload; // simple assignment
}
}
});
// Action creators are auto‑generated
export const { addTodo, toggleTodo, setFilter } = todosSlice.actions;
export default todosSlice.reducer;
Combine the slice with the store:
// file: src/store.js
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './features/todos/todosSlice';
export const store = configureStore({
reducer: {
todos: todosReducer
}
});
Dispatching actions from a component (or a test) looks like:
// file: src/App.js (React example)
import { useDispatch, useSelector } from 'react-redux';
import { addTodo, toggleTodo, setFilter } from './features/todos/todosSlice';
function App() {
const dispatch = useDispatch();
const { items, filter } = useSelector(state => state.todos);
return (
e.key === 'Enter' && dispatch(addTodo(e.target.value))}
placeholder="Add todo"
/>
dispatch(setFilter(e.target.value))}>
{items
.filter(t => filter === 'all' || (filter === 'active' ? !t.completed : t.completed))
.map(t => (
-
dispatch(toggleTodo(t.id))}
/>
{t.text}
))}
);
}
Trade‑offs and Gotchas
- Mutation outside reducers is still forbidden. If you mutate
stateinside a thunk, middleware, or a component, Immer is not active and you will break immutability. The lint ruleredux-toolkit/no-mutating-state(enabled by default in the recommended ESLint config) catches many of these cases. - Returning a new object from a draft reducer defeats Immer. Writing
return { ...state, items: draft.items }inside acreateSlicereducer creates a plain object that bypasses the draft proxy, leading to unexpected reference equality results. - TypeScript users get full inference for the generated action payloads, but must ensure the
stateargument is typed as the draft type (usuallyWritableDraft) when writing custom reducers.
Verify It Yourself
You can confirm that the store updates are immutable with a tiny script. Run it in the project root after installing dependencies.
# Terminal (project root)
npm install @reduxjs/toolkit redux react-redux # if not already present
node -e "
const { configureStore, createSlice } = require('@reduxjs/toolkit');
const slice = createSlice({
name: 'demo',
initialState: { count: 0 },
reducers: { inc(state) { state.count += 1; } }
});
const store = configureStore({ reducer: { demo: slice.reducer } });
const before = store.getState();
store.dispatch(slice.actions.inc());
const after = store.getState();
console.log('Same reference?', before === after); // false
console.log('Demo slice same?', before.demo === after.demo); // false
console.log('Count changed?', after.demo.count); // 1
"
Expected output: Same reference? false, Demo slice same? false, Count changed? 1. The root state and the slice state are new objects, proving Immer produced immutable updates despite the mutable‑looking reducer.
Closing: Adopt Incrementally
If you maintain a legacy Redux codebase, you don't have to rewrite everything at once. Replace one hand‑written reducer with createSlice, run the verification snippet above, and ensure your tests still pass. The reduction in boilerplate and the safety net of Immer make the migration worthwhile, while the caveats above keep you from re‑introducing mutability bugs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.