Concept
createSlice is Redux Toolkit's single biggest boilerplate reduction: it generates a reducer function, matching action creators, and action type strings, all from one object describing a slice's initial state and its reducer cases, written as if you were allowed to mutate state directly.
import { createSlice } from "@reduxjs/toolkit";
const counterSlice = createSlice({
name: "counter",
initialState: { value: 0 },
reducers: {
incremented(state) {
state.value += 1; // looks like mutation...
},
incrementedBy(state, action) {
state.value += action.payload; // ...but ISN'T, underneath
},
},
});
export const { incremented, incrementedBy } = counterSlice.actions; // AUTO-GENERATED action creators
export default counterSlice.reducer;Compare this to the hand-written equivalent from Actions & Reducers, a switch statement, manually-written case strings, and manually-written action creator functions. createSlice generates all of that from the reducers object's own keys: incremented becomes both a case "counter/incremented" inside the generated reducer and an exported incremented() action creator, confirmed to produce { type: "counter/incremented" }, the name field ("counter") automatically prefixes every action type.
The "mutating" syntax is not actually mutation, confirmed via Immer
const store = configureStore({ reducer: { counter: counterSlice.reducer } });
const before = store.getState().counter;
store.dispatch(incremented());
const after = store.getState().counter;
console.log(before === after); // false, a genuinely NEW object
console.log(before.value, after.value); // 0, 1store.dispatch({ type: 'counter/incremented' });
dispatch() sends the action to the store's single root reducer, this is the ONLY way state changes in Redux; nothing else can mutate the store.
createSlice reducers run through Immer, a library that intercepts state.value += 1-style syntax on a special "draft" proxy object and translates it, behind the scenes, into a real, genuinely immutable update, producing a brand-new object rather than actually mutating the original. Confirmed directly: before === after is false, exactly matching the manual immutability discipline required by hand-written reducers in Actions & Reducers, createSlice doesn't relax that rule, it enforces it for you, letting you write code that reads like mutation while Immer guarantees the actual output is a new reference.
Confirmed: deep structural sharing, not just top-level
const slice = createSlice({
name: "app",
initialState: { counter: { value: 0 }, user: { name: "Ada", settings: { theme: "dark" } } },
reducers: {
incremented(state) {
state.counter.value += 1; // ONLY touches state.counter
},
},
});Confirmed by running this exact slice and checking references before/after dispatching incremented(): before.counter === after.counter is false (the touched branch is genuinely new), but before.user === after.user is true, and even before.user.settings === after.user.settings is true, two levels deep. Immer doesn't just clone the top-level object; it performs genuine structural sharing, only constructing new objects along the exact path that actually changed, and reusing every untouched branch's original reference, precisely the reference-stability behavior Actions & Reducers established matters for useSelector and createSelector.
prepare: customizing an action creator's payload
const slice = createSlice({
name: "app",
initialState: { user: { name: "Ada" } },
reducers: {
renamed: {
reducer(state, action) {
state.user.name = action.payload.name;
},
prepare(name) {
return { payload: { name, at: Date.now() } }; // compute the payload shape here
},
},
},
});
slice.actions.renamed("Grace"); // → { type: "app/renamed", payload: { name: "Grace", at: 1234567890 } }Confirmed: the default action-creator shape is (payload) => ({ type, payload }), a single argument becomes the whole payload. When an action needs a different call signature or a computed/derived payload (like a timestamp), the { reducer, prepare } form lets prepare's return value become the actual dispatched action, decoupling the action creator's call signature from its reducer's payload shape.
Try It
Predict the outcome before checking the solution.
const slice = createSlice({
name: "cart",
initialState: { items: [], total: 0 },
reducers: {
itemAdded(state, action) {
state.items.push(action.payload); // array mutation method, INSIDE createSlice
},
},
});
const store = configureStore({ reducer: { cart: slice.reducer } });
const before = store.getState().cart;
store.dispatch(slice.actions.itemAdded({ id: 1, name: "Widget" }));
const after = store.getState().cart;
console.logDoes state.items.push(...), normally a forbidden mutation per Actions & Reducers, cause a problem here?
Solution
false false 0.
Inside a createSlice reducer, state isn't the real state object, it's an Immer "draft" proxy. Calling .push() on state.items (itself a draft) is intercepted by Immer exactly the same way += was: it's translated into a genuine immutable array update. The original before.items array is completely untouched (still length 0), while after.items is a new array containing the pushed item. This is specifically why array/object mutation methods (.push, .splice, .sort, direct property assignment) are safe to use, and idiomatic, inside createSlice reducers, even though those exact same methods are Essential-tier violations in hand-written, non-Immer reducers.
Implement It Yourself
Sketch a simplified version of what Immer does, to see the actual mechanism createSlice relies on:
function produce(baseState, recipe) {
const changes = {};
const draft = new Proxy(baseState, {
set(target, key, value) {
changes[key] = value; // record the change, don't touch the real object
return true;
},
get(target, key) {
return target[key];
},
});
recipe(draft); // run the "mutating" function against the draft
if (Object.keys(changes).length === 0) return
This drastically simplified version captures the essential trick: a Proxy intercepts what looks like direct property assignment, records it separately instead of touching the real object, and only constructs a new object (based on the recorded changes, not by touching the original) once the recipe function finishes. Real Immer does dramatically more, deep/nested proxies, structural sharing at every level, array method interception, but this is the core mechanism that makes "mutating" syntax produce immutable output.
Under the Hood
This directly builds on Actions & Reducers's immutability requirement and Store's reference-comparison-based change detection, createSlice doesn't weaken either rule, it automates compliance with them via Immer. It also connects to configureStore's confirmed finding that its mutation-detection middleware doesn't flag createSlice's syntax, precisely because Immer ensures no actual mutation of the original state object ever occurs.
Common Mistakes
1. Returning a new value AND mutating the draft in the same reducer case
reducers: {
broken(state, action) {
state.value = action.payload; // mutates the draft...
return { value: 999 }; // ❌ ...AND returns a new value, Immer throws in this case
},
}Immer's rule: either mutate the draft (and return nothing/undefined), or return an entirely new value, never both in the same function. Mixing them is a genuine error Immer will throw on, not silently resolve.
2. Reassigning the top-level state parameter itself
reducers: {
reset(state) {
state = initialState; // ❌ reassigns the LOCAL variable, not the actual draft, has NO effect
},
}This is a subtle trap: state = initialState only reassigns the function's local parameter binding, it doesn't tell Immer anything about the draft. The fix is either return initialState; (the "return a new value" form) or mutating specific fields of the existing draft (Object.assign(state, initialState)).
3. Assuming Immer's structural sharing means UNCHANGED reducers can be skipped entirely
// Misconception: "since user.settings is untouched, its reducer case doesn't run at all"Every case in the reducer that's dispatched still runs (per the confirmed combineReducers-delegates-to-every-reducer behavior from Actions & Reducers), Immer's structural sharing means the output reference for untouched branches is preserved, not that the code touching them is skipped. The performance win is downstream (in useSelector/createSelector), not in avoiding the reducer call itself.
Best Practices
- Use
createSlicefor all new reducer logic, "mutating" syntax inside it is not just permitted but idiomatic; the Immer safety net makes it both safer and more readable than manual immutable-update spreading. - Use the
{ reducer, prepare }form whenever an action needs a computed/derived payload or a call signature different from "single argument becomes the whole payload." - Never mix mutating the draft and returning a new value in the same reducer case, pick one per case, consistently.
Performance Tips
- Immer's structural sharing (confirmed: untouched branches keep their exact reference, even multiple levels deep) is what makes
createSlice-generated reducers compatible withuseSelector/createSelector's reference-equality optimizations from Selectors & Reselect, without it, every dispatch would produce entirely new references throughout the tree, and nothing could ever skip a re-render or recomputation. - Immer's proxy-based interception has real (if generally small) overhead compared to hand-written direct object spreading, for the overwhelming majority of apps this is negligible next to the boilerplate/bug-reduction payoff; extremely hot-path reducers touching very large collections on every frame are the rare case worth profiling specifically.
