DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/Redux Ecosystem/createSlice
beginner20 min read·Updated Jun 2025

createSlice

createSlice's 'mutating' reducer syntax (state.counter.value += 1) is confirmed to produce a genuinely immutable update via Immer under the hood — including deep STRUCTURAL SHARING: untouched nested branches (even several levels deep) keep their EXACT original reference, confirmed by direct === checks, while only the actually-changed path gets new objects.

reduxredux-toolkitcreateSliceimmerstate-management

Knowledge Check

10 questions · pass at 70%

0/10
mediummcq

1. When a createSlice reducer writes `state.value += 1`, what actually happens to the ORIGINAL state object?


Interview Questions

2 questions

Cheatsheet

Download-ready reference
Cheatsheet
createSlice({ name, initialState, reducers, extraReducers? })
  → AUTO-GENERATES: reducer function + action creators + action types
    (each `reducers` key → case "name/key" + exported action creator)

"MUTATING" SYNTAX INSIDE createSlice IS SAFE — CONFIRMED via Immer:
  state.value += 1         → translated into a genuine NEW object
  state.items.push(x)      → translated into a genuine NEW array
  before === after          → FALSE (real new reference produced)

CONFIRMED DEEP STRUCTURAL SHARING:
  only touch state.counter → state.counter is NEW,
                              state.user (untouched) is the
                              EXACT SAME reference, even nested
                              (state.user.settings === before too)

{ reducer, prepare } FORM — customize action creator payload:
  prepare(arg) { return { payload: {...computed} }; }
  → decouples action creator's call signature from payload shape

⚠️ NEVER mutate the draft AND return a new value in the same case
⚠️ `state = x` REASSIGNS THE LOCAL VARIABLE — Immer never sees it,
   has ZERO effect. Use `return x;` or mutate draft FIELDS instead.

Structural sharing is WHY createSlice output stays compatible with
  useSelector/createSelector's reference-equality optimizations.

Related topics

configureStorecreateAsyncThunkProEntity AdapterPro

On this page

20m
Knowledge CheckInterview QuestionsCheatsheet

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, 1
createSlice's reducer still funnels through the same dispatch → reducer path
step 1 / 4
store.dispatch({ type: 'counter/incremented' });
Action
active
{ type: 'counter/incremented' }
→
Root reducer
waiting
about to receive it

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
      },
    },



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.



Does 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, 












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 createSlice for 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 with useSelector/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.

},
});
slice.actions.renamed("Grace"); // → { type: "app/renamed", payload: { name: "Grace", at: 1234567890 } }
getState
().cart;
store.dispatch(slice.actions.itemAdded({ id: 1, name: "Widget" }));
const after = store.getState().cart;
console.log(before === after, before.items === after.items, before.items.length);
key
) {
return target[key];
},
});
recipe(draft); // run the "mutating" function against the draft
if (Object.keys(changes).length === 0) return baseState; // nothing changed — return SAME reference
return { ...baseState, ...changes }; // only construct a new object if something ACTUALLY changed
}
const state = { value: 0, unrelated: "x" };
const next = produce(state, (draft) => { draft.value += 1; });
console.log(state === next, next.value); // false 1