Why createSlice and Immer Finally Made Redux Reducers Pleasant to Write
Redux Toolkit's createSlice, powered by Immer, collapses the classic constants/actions/reducer ceremony into one file where reducers read like mutations but stay immutable. Here's how it works and what to watch for.
16 Aug 2026, 08:33 UTC

Ask anyone who maintained a classic Redux codebase what they remember, and it usually isn't the elegant unidirectional data flow. It's the ceremony: a constants file for action types, an action creators file, a reducer with a switch statement, and a spreading exercise ({ ...state, items: [...state.items, newItem] }) repeated for every nested update. Adding one feature meant touching three files and getting the immutability right by hand.
Redux Toolkit (RTK) is the officially recommended way to write Redux today, and its core feature — createSlice, powered by the Immer library — collapses that ceremony into a single file where reducers read like plain mutations but produce safe immutable updates. This post explains how that works, shows a realistic slice, and covers the two gotchas that bite people first.
What createSlice actually generates
A "slice" is one feature's worth of Redux logic: its state shape, its reducers, and its actions. You hand createSlice an object with a name, an initial state, and a set of reducer functions keyed by name. In return you get:
- Action creators, one per reducer, with action types derived automatically (
"cart/itemAdded"). - A reducer function ready to plug into the store.
No action type constants to keep in sync, no switch statement, no default case to remember. The action type string is built from the slice name plus the reducer key, so renaming is a single edit instead of a refactor across files.
Immer: mutations that aren't
The bigger win is what happens inside those reducers. RTK wraps each one with Immer, which gives your function a draft — a proxy of the current state. You write code that looks like mutation:
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], total: 0 },
reducers: {
itemAdded(state, action) {
state.items.push(action.payload);
state.total += action.payload.price;
},
itemRemoved(state, action) {
const index = state.items.findIndex(i => i.id === action.payload);
if (index !== -1) {
state.total -= state.items[index].price;
state.items.splice(index, 1);
}
},
},
});
export const { itemAdded, itemRemoved } = cartSlice.actions;
export default cartSlice.reducer;Immer tracks every write to the draft and produces a new, structurally-shared state tree. Nothing is actually mutated; the original state is untouched, which is what makes time-travel debugging and change detection in React-Redux work. Compare state.items.push(...) to the hand-written equivalent with nested spreads — for deeply nested updates the readability gap is dramatic.
Wiring it up
configureStore replaces createStore plus the usual middleware setup. It enables the Redux DevTools Extension, adds thunk middleware for async logic, and — in development only — runs serializability and immutability checks that throw helpful errors when you put a class instance or Promise into state, or accidentally mutate state outside a slice.
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cartSlice';
export const store = configureStore({
reducer: { cart: cartReducer },
});From a React component, usage is unchanged from classic Redux: useSelector to read, useDispatch to send itemAdded(product). For async work, createAsyncThunk generates pending/fulfilled/rejected actions you handle in the slice's extraReducers field.
Two gotchas worth knowing up front
Don't mix mutation and return. Inside an Immer-wrapped reducer you either mutate the draft or return a brand-new state — never both in the same code path. Doing both throws an error, because Immer can't reconcile "I changed the draft" with "actually, use this other object instead." Pick one style per reducer.
Keep state serializable. The dev-mode middleware warns when non-serializable values (class instances, functions, Promises, Dates in some setups) land in state or actions. Beyond the warning, such values break time-travel debugging and persistence. Store plain objects, arrays, strings, numbers, and booleans; keep everything else in component state or a cache layer.
The honest trade-off
RTK removes boilerplate, but it doesn't remove Redux's conceptual weight. You still need to be comfortable with a single store, normalized state for relational data, and the action/reducer mental model. And if most of your state is really server data — fetched, cached, refetched — hand-writing thunks and slices for it is often the wrong tool. RTK Query (built into RTK) or a dedicated data-fetching library handles caching, invalidation, and loading states that you'd otherwise reinvent.
Where to start
You don't need a rewrite to benefit. Slices and legacy hand-written reducers coexist in the same store, so the practical path is incremental: pick one small, self-contained reducer — a UI preferences reducer is ideal — and port it to createSlice. Then open Redux DevTools, dispatch the new actions, and confirm the state transitions look identical to the old ones. If the diff in your reducer file is mostly deleted lines, you're doing it right. Note that RTK's APIs and defaults evolve across major versions, so check the current docs before adopting patterns from older tutorials.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.