useImmerReducer(recipe, initialState) is a useReducer whose reducer mutates a draft instead of building the next state with spreads. You write draft.user.name = 'Ada' or draft.items.push(item), and the hook produces the immutable next state a reducer is required to return — with the branches you did not touch shared by reference. It is the hook behind use-immer and the shape of every Redux Toolkit createSlice.
You are handed a working produce(base, recipe) — it is the immer-produce question, complete. Your job is only the wiring: turn it into a reducer hook. The interesting part is what changes once produce is your reducer, so the exercise stays single-axis.
function useImmerReducer<S, A>(
// Mutate `draft` in place, OR return a replacement state — never both.
recipe: (draft: S, action: A) => void | S,
initialState: S,
): [state: S, dispatch: (action: A) => void];
const recipe = (draft, action) => {
if (action.type === 'increment') draft.count += 1;
if (action.type === 'setBy') draft.count += action.by;
};
const [state, dispatch] = useImmerReducer(recipe, { count: 0 });
dispatch({ type: 'increment' }); // state.count is 1 on the next render
dispatch({ type: 'setBy', by: 5 }); // 6
const recipe = (draft, action) => {
if (action.type === 'add') draft.items.push({ id: action.id, done: false });
if (action.type === 'reset') return action.fresh; // return a replacement
};
const [board, dispatch] = useImmerReducer(recipe, {
title: 'todo',
items: [],
settings: { theme: 'dark' },
});
dispatch({ type: 'add', id: 1 });
// board.items grows; board.settings is the SAME object as before (shared).
produce is provided — wire it into a useReducer; do not rebuild it. See immer-produce for how it works.state.items.sort() or state.list.push(x) downstream throws instead of silently corrupting the store.React.memo child reading an unchanged slice skips its re-render.dispatch is stable — its identity never changes, so it is safe to pass to memoized children or capture in a mount-only effect.You will wire immer's produce into a useReducer so the recipe can mutate a draft — and meet the three things that surprise everyone the first time a reducer looks like it broke the rules.
A reducer has two rules drilled into every React developer: it must be pure (never touch the state it was handed) and it must return the next state. Keeping both is what makes immutable updates verbose — to change one field three levels deep you spread every object on the way down, and a form with eight fields becomes eight spreads that all have to agree on the shape. An immer reducer deletes the spreads: you write draft.user.name = 'Ada' and get a new immutable state back. The catch is that it now looks like it broke both rules — it mutates its argument, and it returns nothing — and three real behaviors fall out of that illusion.
produce(base, recipe) hands your recipe a draft: a proxy over the old state that you can write to as if editing in place. It never touches the base — it copies the nodes you changed onto a fresh object and returns that. So a recipe that reads like a mutation is actually pure, and a recipe that returns nothing still produces the next state, because produce returns it for you. The two rules only look broken.
You are handed a working produce (it is the immer-produce question, complete). Your only job is the wiring, so this exercise is single-axis: the reducer, plus the three surprises.
The obvious way to let a recipe "mutate" safely is to deep-clone the state first, let the recipe scribble on the clone, and hand the clone back:
function useImmerReducer(recipe, initialState) {
const reducer = (state, action) => {
const clone = JSON.parse(JSON.stringify(state)); // deep-copy everything
recipe(clone, action);
return clone;
};
return useReducer(reducer, initialState);
}
The state genuinely updates, so it looks done — measured against this question's suite it passes nine of the fifteen tests, every basic update among them. Then it fails the six that matter. A clone gives every branch a brand-new reference, so a React.memo child reading a slice you never touched re-renders anyway — the exact cost immutable state was supposed to avoid. The clone is not frozen, so a stray mutation later slips through and corrupts the store instead of throwing. It ignores a recipe that returns a replacement. And a no-op still allocates a fresh object, so React never bails out of the render. This is the same deep-clone trap immer-produce warns about, now wearing a reducer.
Hand produce a draft and let it do the copying. The hook is the wiring; the provided produce (everything below the divider) is the immer-produce solution, shown so the file runs on its own.
const { useReducer } = require('react');
// A working `produce` is provided below (the immer-produce question, complete).
// The whole hook is the wiring: run the recipe against a draft inside produce.
function useImmerReducer(recipe, initialState) {
// A fresh reducer every render is fine: React runs whichever reducer it holds
// at the moment it processes a dispatch, and this one closes over the NEWEST
// `recipe`, so a recipe that reads props is never stale. `dispatch` identity is
// React's to keep stable — it does, whatever the reducer is.
const reducer = (state, action) => produce(state, (draft) => recipe(draft, action));
return useReducer(reducer, initialState);
}
module.exports = { useImmerReducer };
// ── PROVIDED: produce() — do not edit ─────────────────────────────────────────
// This is the immer-produce question, complete, plus the two items that question
// left as "Going further": it deep-freezes the result, and it lets a recipe
// RETURN a replacement instead of mutating. Contract:
// produce(base, recipe)
// • recipe(draft) mutates `draft`; you get a new immutable state back.
// • branches you did not touch keep their reference (structural sharing).
// • a recipe that changes nothing returns `base` itself.
// • the result is deep-frozen — mutating it later throws.
// • a recipe may instead RETURN a fresh replacement state, as long as it did
// not also mutate the draft (doing both throws).
// You do not need to read this to solve the exercise. See /questions/immer-produce
// for how the copy-on-write proxy works.
const DRAFT_STATE = Symbol('immer-draft-state');
function isDraftable(value) {
if (!value || typeof value !== 'object') return false;
if (Array.isArray(value)) return true;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function isDraft(value) {
return !!(value && value[DRAFT_STATE]);
}
function shallowCopy(base) {
return Array.isArray(base) ? base.slice() : { ...base };
}
function latest(state) {
return state.copy || state.base;
}
function prepareCopy(state) {
if (!state.copy) state.copy = shallowCopy(state.base);
}
function markChanged(state) {
if (!state.modified) {
state.modified = true;
prepareCopy(state);
if (state.parent) markChanged(state.parent);
}
}
const handler = {
get(state, prop) {
if (prop === DRAFT_STATE) return state;
const source = latest(state);
const value = source[prop];
if (state.finalized || !isDraftable(value)) return value;
if (value === state.base[prop]) {
prepareCopy(state);
state.copy[prop] = createDraft(value, state);
return state.copy[prop];
}
return value;
},
set(state, prop, value) {
if (!state.modified) {
const had = prop in latest(state);
if (had && Object.is(latest(state)[prop], value)) return true;
markChanged(state);
}
state.copy[prop] = value;
return true;
},
has(state, prop) {
return prop in latest(state);
},
ownKeys(state) {
return Reflect.ownKeys(latest(state));
},
getOwnPropertyDescriptor(state, prop) {
const owner = latest(state);
const desc = Reflect.getOwnPropertyDescriptor(owner, prop);
if (!desc) return desc;
return { writable: true, configurable: true, enumerable: desc.enumerable, value: owner[prop] };
},
deleteProperty(state, prop) {
if (prop in state.base) markChanged(state);
if (state.copy) delete state.copy[prop];
return true;
},
};
function createState(base, parent) {
const state = { base, copy: null, parent, modified: false, finalized: false, draft: null };
state.draft = new Proxy(state, handler);
return state;
}
function createDraft(base, parent) {
return createState(base, parent).draft;
}
function eachOwn(obj, cb) {
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) cb(i, obj[i]);
} else {
for (const key of Object.keys(obj)) cb(key, obj[key]);
}
}
function finalize(draft) {
const state = draft && draft[DRAFT_STATE];
if (!state) return draft;
if (!state.modified) return state.base;
if (state.finalized) return state.copy;
state.finalized = true;
eachOwn(state.copy, (key, child) => {
if (isDraft(child)) state.copy[key] = finalize(child);
});
return state.copy;
}
// Deep-freeze so a stray later mutation throws (immer's default).
function deepFreeze(obj) {
if (obj && typeof obj === 'object' && !Object.isFrozen(obj)) {
Object.freeze(obj);
if (Array.isArray(obj)) {
for (const v of obj) deepFreeze(v);
} else {
for (const k of Object.keys(obj)) deepFreeze(obj[k]);
}
}
return obj;
}
// A returned replacement may still reference drafts (e.g. `{ ...draft, x: 0 }`);
// swap any draft it holds for its finalized value before freezing.
function finalizeReturn(value) {
if (isDraft(value)) return finalize(value);
if (isDraftable(value)) {
eachOwn(value, (key, child) => {
if (isDraft(child)) value[key] = finalize(child);
else if (isDraftable(child)) finalizeReturn(child);
});
}
return value;
}
function produce(baseState, recipe) {
if (!isDraftable(baseState)) return recipe(baseState);
const rootState = createState(baseState, null);
const result = recipe(rootState.draft);
// A recipe either mutates the draft OR returns a replacement — never both.
if (result !== undefined && result !== rootState.draft) {
if (rootState.modified) {
throw new Error(
'[Immer] An immer producer returned a new value *and* modified its draft. ' +
'Either return a new value *or* modify the draft.',
);
}
return deepFreeze(finalizeReturn(result));
}
return deepFreeze(finalize(rootState.draft));
}
Three things about the hook earn a sentence. The reducer is rebuilt on every render, and that costs nothing: React runs whichever reducer it is holding at the moment it processes a dispatch, so a fresh one is never a stale one — and because this reducer reads recipe rather than remembering it, a recipe that closes over a prop always sees the newest prop. This is the same freedom useMethods leans on. dispatch keeps a stable identity no matter how often the reducer is rebuilt — that is React's guarantee, not ours. And produce carries all the behavior that makes this a real immer reducer.
Start with { title: 'todo', items: [{ id: 1 }], settings: { theme: 'dark' } } and dispatch { type: 'add', id: 2 }, where the recipe is draft.items.push({ id: action.id }).
produce(state, (draft) => recipe(draft, action)).produce hands the recipe a draft. Reading draft.items returns a child draft over the items array; title and settings are never read.items and the root as changed, so each is shallow-copied; title and settings are still the originals.produce finalizes. The new root points at a new items array [{ id: 1 }, { id: 2 }], and at the same title and settings references as before. Then it deep-freezes the whole result.produce uses the finalized draft. useReducer stores it and React re-renders.next.settings === state.settings (shared, so a memo child reading settings skips), next.items is a new frozen array, and the original state object was never touched.produce deep-freezes what it returns — in every environment, not just development. That turns the most common reducer-state reflex into a loud error. Reach into the state React handed you and mutate it in place — state.items.sort() before rendering, state.list.reverse() in a handler — and it throws instead of silently corrupting the store behind React's back.
The fix is to not mutate state you were handed: sort a copy ([...state.items].sort()), or make the change inside a recipe where you are editing a draft, not the frozen result.
A plain reducer's deepest reflex is "always return the next state." An immer reducer inverts it: the normal case is to mutate the draft and return nothing. You only return a value for a wholesale replacement — a reset, (draft, action) => action.freshState — and then you must not also mutate the draft. Doing both throws, because produce cannot tell which one you meant. Forgetting to return is a bug in a plain reducer; here it is the correct, ordinary path.
Because produce copies only the branches you touched and shares the rest by reference, a reducer update is cheap in exactly the way React cares about. A React.memo child reading an untouched slice gets the same reference back and skips its re-render; a useMemo or a selector keyed on that slice does not recompute.
Why the untouched branches keep their reference is the whole subject of immer-produce; here it is the reason to prefer this over a clone reducer.
This hook is use-immer's useImmerReducer, near enough. Its version is useReducer(useMemo(() => produce(reducer), [reducer]), initialState) — immer's curried produce(recipe) form, memoized on the reducer. That memo is a harmless optimization here precisely because dispatch is stable no matter what, which is the opposite of useMethods, where memoizing on the caller's factory quietly broke the hook's stable-identity promise.
Redux Toolkit's createSlice is an immer reducer, and it is the single most-used state pattern in the React ecosystem. Every case reducer mutates a draft (state.items.push(...)), getState() comes back deep-frozen, a case reducer that returns a value replaces the state wholesale, and mutating and returning throws the same immer error you see above. Learning this hook is learning what happens under every createSlice you write.
state.list.sort() in render or a handler now throws, because the state is frozen. Fix: copy first ([...state.list].sort()), or do it inside a recipe on the draft.draft.count++ followed by return something throws. Fix: pick one — mutate and return nothing (the norm), or return a fresh replacement and touch nothing.return state is wrong here; a recipe with no return statement is the correct case. Returning the draft you were given is fine too — it means the same as returning nothing.produce.initialState is handed back as-is on the first render; it only becomes frozen once a dispatch runs produce. Freeze the initial value yourself if you rely on it.produce(recipe) (curried), which is (state, action) => produce(state, (d) => recipe(d, action)) in one call; and useReducer's third argument is a lazy initializer that use-immer forwards.setAutoFreeze(false) trades the safety net for a little speed. Immer keeps freezing on by default in production too, which is why the throw is reliable everywhere — not a development-only guard.produce changed, which powers time-travel and syncing. Record every write to build them.createSlice gives you this reducer plus action creators generated from the case names — build the hook once to understand it, then use theirs.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useImmerReducer(recipe, initialState) is a useReducer whose reducer mutates a draft instead of building the next state with spreads. You write draft.user.name = 'Ada' or draft.items.push(item), and the hook produces the immutable next state a reducer is required to return — with the branches you did not touch shared by reference. It is the hook behind use-immer and the shape of every Redux Toolkit createSlice.
You are handed a working produce(base, recipe) — it is the immer-produce question, complete. Your job is only the wiring: turn it into a reducer hook. The interesting part is what changes once produce is your reducer, so the exercise stays single-axis.
function useImmerReducer<S, A>(
// Mutate `draft` in place, OR return a replacement state — never both.
recipe: (draft: S, action: A) => void | S,
initialState: S,
): [state: S, dispatch: (action: A) => void];
const recipe = (draft, action) => {
if (action.type === 'increment') draft.count += 1;
if (action.type === 'setBy') draft.count += action.by;
};
const [state, dispatch] = useImmerReducer(recipe, { count: 0 });
dispatch({ type: 'increment' }); // state.count is 1 on the next render
dispatch({ type: 'setBy', by: 5 }); // 6
const recipe = (draft, action) => {
if (action.type === 'add') draft.items.push({ id: action.id, done: false });
if (action.type === 'reset') return action.fresh; // return a replacement
};
const [board, dispatch] = useImmerReducer(recipe, {
title: 'todo',
items: [],
settings: { theme: 'dark' },
});
dispatch({ type: 'add', id: 1 });
// board.items grows; board.settings is the SAME object as before (shared).
produce is provided — wire it into a useReducer; do not rebuild it. See immer-produce for how it works.state.items.sort() or state.list.push(x) downstream throws instead of silently corrupting the store.React.memo child reading an unchanged slice skips its re-render.dispatch is stable — its identity never changes, so it is safe to pass to memoized children or capture in a mount-only effect.You will wire immer's produce into a useReducer so the recipe can mutate a draft — and meet the three things that surprise everyone the first time a reducer looks like it broke the rules.
A reducer has two rules drilled into every React developer: it must be pure (never touch the state it was handed) and it must return the next state. Keeping both is what makes immutable updates verbose — to change one field three levels deep you spread every object on the way down, and a form with eight fields becomes eight spreads that all have to agree on the shape. An immer reducer deletes the spreads: you write draft.user.name = 'Ada' and get a new immutable state back. The catch is that it now looks like it broke both rules — it mutates its argument, and it returns nothing — and three real behaviors fall out of that illusion.
produce(base, recipe) hands your recipe a draft: a proxy over the old state that you can write to as if editing in place. It never touches the base — it copies the nodes you changed onto a fresh object and returns that. So a recipe that reads like a mutation is actually pure, and a recipe that returns nothing still produces the next state, because produce returns it for you. The two rules only look broken.
You are handed a working produce (it is the immer-produce question, complete). Your only job is the wiring, so this exercise is single-axis: the reducer, plus the three surprises.
The obvious way to let a recipe "mutate" safely is to deep-clone the state first, let the recipe scribble on the clone, and hand the clone back:
function useImmerReducer(recipe, initialState) {
const reducer = (state, action) => {
const clone = JSON.parse(JSON.stringify(state)); // deep-copy everything
recipe(clone, action);
return clone;
};
return useReducer(reducer, initialState);
}
The state genuinely updates, so it looks done — measured against this question's suite it passes nine of the fifteen tests, every basic update among them. Then it fails the six that matter. A clone gives every branch a brand-new reference, so a React.memo child reading a slice you never touched re-renders anyway — the exact cost immutable state was supposed to avoid. The clone is not frozen, so a stray mutation later slips through and corrupts the store instead of throwing. It ignores a recipe that returns a replacement. And a no-op still allocates a fresh object, so React never bails out of the render. This is the same deep-clone trap immer-produce warns about, now wearing a reducer.
Hand produce a draft and let it do the copying. The hook is the wiring; the provided produce (everything below the divider) is the immer-produce solution, shown so the file runs on its own.
const { useReducer } = require('react');
// A working `produce` is provided below (the immer-produce question, complete).
// The whole hook is the wiring: run the recipe against a draft inside produce.
function useImmerReducer(recipe, initialState) {
// A fresh reducer every render is fine: React runs whichever reducer it holds
// at the moment it processes a dispatch, and this one closes over the NEWEST
// `recipe`, so a recipe that reads props is never stale. `dispatch` identity is
// React's to keep stable — it does, whatever the reducer is.
const reducer = (state, action) => produce(state, (draft) => recipe(draft, action));
return useReducer(reducer, initialState);
}
module.exports = { useImmerReducer };
// ── PROVIDED: produce() — do not edit ─────────────────────────────────────────
// This is the immer-produce question, complete, plus the two items that question
// left as "Going further": it deep-freezes the result, and it lets a recipe
// RETURN a replacement instead of mutating. Contract:
// produce(base, recipe)
// • recipe(draft) mutates `draft`; you get a new immutable state back.
// • branches you did not touch keep their reference (structural sharing).
// • a recipe that changes nothing returns `base` itself.
// • the result is deep-frozen — mutating it later throws.
// • a recipe may instead RETURN a fresh replacement state, as long as it did
// not also mutate the draft (doing both throws).
// You do not need to read this to solve the exercise. See /questions/immer-produce
// for how the copy-on-write proxy works.
const DRAFT_STATE = Symbol('immer-draft-state');
function isDraftable(value) {
if (!value || typeof value !== 'object') return false;
if (Array.isArray(value)) return true;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function isDraft(value) {
return !!(value && value[DRAFT_STATE]);
}
function shallowCopy(base) {
return Array.isArray(base) ? base.slice() : { ...base };
}
function latest(state) {
return state.copy || state.base;
}
function prepareCopy(state) {
if (!state.copy) state.copy = shallowCopy(state.base);
}
function markChanged(state) {
if (!state.modified) {
state.modified = true;
prepareCopy(state);
if (state.parent) markChanged(state.parent);
}
}
const handler = {
get(state, prop) {
if (prop === DRAFT_STATE) return state;
const source = latest(state);
const value = source[prop];
if (state.finalized || !isDraftable(value)) return value;
if (value === state.base[prop]) {
prepareCopy(state);
state.copy[prop] = createDraft(value, state);
return state.copy[prop];
}
return value;
},
set(state, prop, value) {
if (!state.modified) {
const had = prop in latest(state);
if (had && Object.is(latest(state)[prop], value)) return true;
markChanged(state);
}
state.copy[prop] = value;
return true;
},
has(state, prop) {
return prop in latest(state);
},
ownKeys(state) {
return Reflect.ownKeys(latest(state));
},
getOwnPropertyDescriptor(state, prop) {
const owner = latest(state);
const desc = Reflect.getOwnPropertyDescriptor(owner, prop);
if (!desc) return desc;
return { writable: true, configurable: true, enumerable: desc.enumerable, value: owner[prop] };
},
deleteProperty(state, prop) {
if (prop in state.base) markChanged(state);
if (state.copy) delete state.copy[prop];
return true;
},
};
function createState(base, parent) {
const state = { base, copy: null, parent, modified: false, finalized: false, draft: null };
state.draft = new Proxy(state, handler);
return state;
}
function createDraft(base, parent) {
return createState(base, parent).draft;
}
function eachOwn(obj, cb) {
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) cb(i, obj[i]);
} else {
for (const key of Object.keys(obj)) cb(key, obj[key]);
}
}
function finalize(draft) {
const state = draft && draft[DRAFT_STATE];
if (!state) return draft;
if (!state.modified) return state.base;
if (state.finalized) return state.copy;
state.finalized = true;
eachOwn(state.copy, (key, child) => {
if (isDraft(child)) state.copy[key] = finalize(child);
});
return state.copy;
}
// Deep-freeze so a stray later mutation throws (immer's default).
function deepFreeze(obj) {
if (obj && typeof obj === 'object' && !Object.isFrozen(obj)) {
Object.freeze(obj);
if (Array.isArray(obj)) {
for (const v of obj) deepFreeze(v);
} else {
for (const k of Object.keys(obj)) deepFreeze(obj[k]);
}
}
return obj;
}
// A returned replacement may still reference drafts (e.g. `{ ...draft, x: 0 }`);
// swap any draft it holds for its finalized value before freezing.
function finalizeReturn(value) {
if (isDraft(value)) return finalize(value);
if (isDraftable(value)) {
eachOwn(value, (key, child) => {
if (isDraft(child)) value[key] = finalize(child);
else if (isDraftable(child)) finalizeReturn(child);
});
}
return value;
}
function produce(baseState, recipe) {
if (!isDraftable(baseState)) return recipe(baseState);
const rootState = createState(baseState, null);
const result = recipe(rootState.draft);
// A recipe either mutates the draft OR returns a replacement — never both.
if (result !== undefined && result !== rootState.draft) {
if (rootState.modified) {
throw new Error(
'[Immer] An immer producer returned a new value *and* modified its draft. ' +
'Either return a new value *or* modify the draft.',
);
}
return deepFreeze(finalizeReturn(result));
}
return deepFreeze(finalize(rootState.draft));
}
Three things about the hook earn a sentence. The reducer is rebuilt on every render, and that costs nothing: React runs whichever reducer it is holding at the moment it processes a dispatch, so a fresh one is never a stale one — and because this reducer reads recipe rather than remembering it, a recipe that closes over a prop always sees the newest prop. This is the same freedom useMethods leans on. dispatch keeps a stable identity no matter how often the reducer is rebuilt — that is React's guarantee, not ours. And produce carries all the behavior that makes this a real immer reducer.
Start with { title: 'todo', items: [{ id: 1 }], settings: { theme: 'dark' } } and dispatch { type: 'add', id: 2 }, where the recipe is draft.items.push({ id: action.id }).
produce(state, (draft) => recipe(draft, action)).produce hands the recipe a draft. Reading draft.items returns a child draft over the items array; title and settings are never read.items and the root as changed, so each is shallow-copied; title and settings are still the originals.produce finalizes. The new root points at a new items array [{ id: 1 }, { id: 2 }], and at the same title and settings references as before. Then it deep-freezes the whole result.produce uses the finalized draft. useReducer stores it and React re-renders.next.settings === state.settings (shared, so a memo child reading settings skips), next.items is a new frozen array, and the original state object was never touched.produce deep-freezes what it returns — in every environment, not just development. That turns the most common reducer-state reflex into a loud error. Reach into the state React handed you and mutate it in place — state.items.sort() before rendering, state.list.reverse() in a handler — and it throws instead of silently corrupting the store behind React's back.
The fix is to not mutate state you were handed: sort a copy ([...state.items].sort()), or make the change inside a recipe where you are editing a draft, not the frozen result.
A plain reducer's deepest reflex is "always return the next state." An immer reducer inverts it: the normal case is to mutate the draft and return nothing. You only return a value for a wholesale replacement — a reset, (draft, action) => action.freshState — and then you must not also mutate the draft. Doing both throws, because produce cannot tell which one you meant. Forgetting to return is a bug in a plain reducer; here it is the correct, ordinary path.
Because produce copies only the branches you touched and shares the rest by reference, a reducer update is cheap in exactly the way React cares about. A React.memo child reading an untouched slice gets the same reference back and skips its re-render; a useMemo or a selector keyed on that slice does not recompute.
Why the untouched branches keep their reference is the whole subject of immer-produce; here it is the reason to prefer this over a clone reducer.
This hook is use-immer's useImmerReducer, near enough. Its version is useReducer(useMemo(() => produce(reducer), [reducer]), initialState) — immer's curried produce(recipe) form, memoized on the reducer. That memo is a harmless optimization here precisely because dispatch is stable no matter what, which is the opposite of useMethods, where memoizing on the caller's factory quietly broke the hook's stable-identity promise.
Redux Toolkit's createSlice is an immer reducer, and it is the single most-used state pattern in the React ecosystem. Every case reducer mutates a draft (state.items.push(...)), getState() comes back deep-frozen, a case reducer that returns a value replaces the state wholesale, and mutating and returning throws the same immer error you see above. Learning this hook is learning what happens under every createSlice you write.
state.list.sort() in render or a handler now throws, because the state is frozen. Fix: copy first ([...state.list].sort()), or do it inside a recipe on the draft.draft.count++ followed by return something throws. Fix: pick one — mutate and return nothing (the norm), or return a fresh replacement and touch nothing.return state is wrong here; a recipe with no return statement is the correct case. Returning the draft you were given is fine too — it means the same as returning nothing.produce.initialState is handed back as-is on the first render; it only becomes frozen once a dispatch runs produce. Freeze the initial value yourself if you rely on it.produce(recipe) (curried), which is (state, action) => produce(state, (d) => recipe(d, action)) in one call; and useReducer's third argument is a lazy initializer that use-immer forwards.setAutoFreeze(false) trades the safety net for a little speed. Immer keeps freezing on by default in production too, which is why the throw is reliable everywhere — not a development-only guard.produce changed, which powers time-travel and syncing. Record every write to build them.createSlice gives you this reducer plus action creators generated from the case names — build the hook once to understand it, then use theirs.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.