Immer: Immutable State Made Simple
Complete guide to Immer for immutable state management — produce, patches, snapshots, and undo/redo patterns
Introduction
Immer is a JavaScript library that makes working with immutable state simple and intuitive. Instead of writing complex nested spread operators or using Object.assign(), Immer lets you write code that appears to mutate state directly while actually producing immutable updates behind the scenes.
Why Immer?
The Problem with Immutable Updates
// Without Immer - nested updates are verbose
const state = {
user: {
profile: {
address: {
city: 'Paris',
zip: '75001'
}
}
},
items: [{ id: 1, name: 'Item 1' }]
};
// Update nested city
const newState = {
...state,
user: {
...state.user,
profile: {
...state.user.profile,
address: {
...state.user.profile.address,
city: 'London'
}
}
}
};
// Update item in array
const newItems = state.items.map(item =>
item.id === 1 ? { ...item, name: 'Updated' } : item
);
The Immer Solution
import { produce } from 'immer';
const newState = produce(state, draft => {
draft.user.profile.address.city = 'London';
const item = draft.items.find(i => i.id === 1);
if (item) {
item.name = 'Updated';
}
});
// Much cleaner and more readable!
Installation
# npm
npm install immer
# yarn
yarn add immer
# pnpm
pnpm add immer
Core API
produce()
The main function that creates immutable updates.
Signature: produce(currentState, recipeFunction)
import { produce } from 'immer';
const baseState = {
todos: [{ done: false, text: 'Learn Immer' }],
user: { name: 'Alice', age: 30 }
};
const nextState = produce(baseState, draft => {
// Mutate the draft freely
draft.todos[0].done = true;
draft.user.age = 31;
});
// baseState is unchanged
console.log(baseState.todos[0].done); // false
// nextState has the updates
console.log(nextState.todos[0].done); // true
// But they share unchanged parts (structural sharing)
console.log(baseState.user === nextState.user); // false
console.log(baseState.todos === nextState.todos); // false
Curried produce (Recipe Reuse)
import { produce } from 'immer';
// Create a reusable updater
const addTodo = (text) => produce((draft) => {
draft.todos.push({ done: false, text });
});
const incrementAge = produce((draft) => {
draft.user.age++;
});
// Use the curried functions
const state1 = { todos: [], user: { age: 30 } };
const state2 = addTodo(state1, 'Learn Redux');
const state3 = incrementAge(state2);
console.log(state3);
// { todos: [{ done: false, text: 'Learn Redux' }], user: { age: 31 } }
produce with Initial State
import { produce } from 'immer';
// Provide initial state as second argument
const updater = produce((draft, action) => {
switch (action.type) {
case 'ADD_TODO':
draft.todos.push({ text: action.text, done: false });
break;
case 'TOGGLE_TODO':
draft.todos[action.index].done = !draft.todos[action.index].done;
break;
}
}, { todos: [] });
// First call uses initial state
const state1 = updater({ type: 'ADD_TODO', text: 'First' });
// Subsequent calls use previous state
const state2 = updater(state1, { type: 'ADD_TODO', text: 'Second' });
const state3 = updater(state2, { type: 'TOGGLE_TODO', index: 0 });
Working with Arrays
Adding Items
import { produce } from 'immer';
const state = { items: [1, 2, 3] };
const newState = produce(state, draft => {
// Push to end
draft.items.push(4);
// Unshift to beginning
draft.items.unshift(0);
// Insert at index
draft.items.splice(2, 0, 1.5);
// Concatenate
draft.items.push(...[5, 6]);
});
console.log(newState.items); // [0, 1, 1.5, 2, 3, 4, 5, 6]
Removing Items
const state = { items: [1, 2, 3, 4, 5] };
const newState = produce(state, draft => {
// Pop from end
draft.items.pop();
// Shift from beginning
draft.items.shift();
// Splice to remove
draft.items.splice(1, 2); // Remove 2 items at index 1
});
console.log(newState.items); // [2, 5]
Modifying Items
const state = {
users: [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' }
]
};
const newState = produce(state, draft => {
// Find and modify
const user = draft.users.find(u => u.id === 2);
if (user) {
user.name = 'Bobby';
}
// Modify by index
draft.users[0].name = 'Alicia';
// Map-style update
draft.users.forEach(user => {
user.name = user.name.toUpperCase();
});
});
console.log(newState.users);
// [
// { id: 1, name: 'ALICIA' },
// { id: 2, name: 'BOBBY' },
// { id: 3, name: 'CHARLIE' }
// ]
Working with Maps and Sets
Maps
import { produce } from 'immer';
const state = {
map: new Map([
['key1', 'value1'],
['key2', 'value2']
])
};
const newState = produce(state, draft => {
// Set new values
draft.map.set('key3', 'value3');
// Update existing
draft.map.set('key1', 'updated1');
// Delete
draft.map.delete('key2');
// Clear all
// draft.map.clear();
});
console.log([...newState.map.entries()]);
// [['key1', 'updated1'], ['key3', 'value3']]
Sets
const state = {
set: new Set([1, 2, 3])
};
const newState = produce(state, draft => {
// Add values
draft.set.add(4);
draft.set.add(5);
// Delete value
draft.set.delete(2);
});
console.log([...newState.set]); // [1, 3, 4, 5]
Patches: Git-like State Tracking
Immer can track all changes as patches, similar to git diffs.
enablePatches
import { produce, enablePatches } from 'immer';
enablePatches();
const baseState = {
todos: [{ text: 'Learn Immer', done: false }],
counter: 0
};
const [nextState, patches, inversePatches] = produce(
baseState,
draft => {
draft.todos[0].done = true;
draft.todos.push({ text: 'Learn Patches', done: false });
draft.counter++;
}
);
console.log(patches);
// [
// { op: 'replace', path: ['todos', 0, 'done'], value: true },
// { op: 'add', path: ['todos', 1], value: { text: 'Learn Patches', done: false } },
// { op: 'replace', path: ['counter'], value: 1 }
// ]
console.log(inversePatches);
// Inverse operations to revert back to baseState
Applying Patches
import { applyPatch } from 'immer';
const originalState = { count: 0, items: [1, 2, 3] };
const patches = [
{ op: 'replace', path: ['count'], value: 1 },
{ op: 'add', path: ['items', 3], value: 4 }
];
const newState = applyPatch(originalState, patches);
console.log(newState);
// { count: 1, items: [1, 2, 3, 4] }
Patch Operations
// Available patch operations:
// Add a new value
{ op: 'add', path: ['todos', 2], value: { text: 'New' } }
// Replace an existing value
{ op: 'replace', path: ['counter'], value: 5 }
// Remove a value
{ op: 'remove', path: ['todos', 0] }
Undo/Redo Pattern
Immer's patches make implementing undo/redo straightforward.
Simple Undo/Redo
import { produce, enablePatches } from 'immer';
enablePatches();
class UndoRedo {
constructor(initialState) {
this.history = [initialState];
this.currentIndex = 0;
}
update(recipe) {
const currentState = this.history[this.currentIndex];
const [nextState, patches, inversePatches] = produce(
currentState,
recipe
);
// Remove any forward history if we branched
this.history = this.history.slice(0, this.currentIndex + 1);
// Add new state with its inverse patches
this.history.push({
state: nextState,
inversePatches
});
this.currentIndex++;
return nextState;
}
undo() {
if (this.currentIndex === 0) return this.history[0];
const entry = this.history[this.currentIndex];
const previousState = applyPatch(
entry.state,
entry.inversePatches
);
this.currentIndex--;
return previousState;
}
redo() {
if (this.currentIndex >= this.history.length - 1) {
return this.history[this.currentIndex].state;
}
this.currentIndex++;
return this.history[this.currentIndex].state;
}
canUndo() {
return this.currentIndex > 0;
}
canRedo() {
return this.currentIndex < this.history.length - 1;
}
}
// Usage
const manager = new UndoRedo({ count: 0, items: [] });
manager.update(draft => {
draft.count++;
draft.items.push('A');
});
manager.update(draft => {
draft.count++;
draft.items.push('B');
});
console.log(manager.canUndo()); // true
const previous = manager.undo(); // Back to first update
const back = manager.undo(); // Back to initial
const forward = manager.redo(); // Forward to first update
Redux Undo with Immer
import { produce, enablePatches } from 'immer';
enablePatches();
const initialState = { counter: 0 };
function reducer(state = initialState, action) {
switch (action.type) {
case 'INCREMENT':
return produce(state, draft => {
draft.counter++;
});
case 'UNDO':
if (!state.past || state.past.length === 0) return state;
const previous = state.past[state.past.length - 1];
const newPast = state.past.slice(0, -1);
return {
...previous,
past: newPast,
future: [state, ...state.future || []]
};
case 'REDO':
if (!state.future || state.future.length === 0) return state;
const next = state.future[0];
const newFuture = state.future.slice(1);
return {
...next,
past: [...state.past, state],
future: newFuture
};
default:
return state;
}
}
Snapshots and Time Travel
import { produce, enablePatches } from 'immer';
enablePatches();
class TimeTravel {
constructor(initialState) {
this.snapshots = [{
state: initialState,
timestamp: Date.now(),
label: 'Initial'
}];
this.currentIndex = 0;
}
save(label = '') {
const currentState = this.snapshots[this.currentIndex].state;
this.snapshots = this.snapshots.slice(0, this.currentIndex + 1);
this.snapshots.push({
state: currentState,
timestamp: Date.now(),
label
});
this.currentIndex++;
}
update(recipe, label = '') {
const currentState = this.snapshots[this.currentIndex].state;
const nextState = produce(currentState, recipe);
this.snapshots = this.snapshots.slice(0, this.currentIndex + 1);
this.snapshots.push({
state: nextState,
timestamp: Date.now(),
label
});
this.currentIndex++;
return nextState;
}
travelTo(index) {
if (index < 0 || index >= this.snapshots.length) {
throw new Error('Invalid index');
}
this.currentIndex = index;
return this.snapshots[index].state;
}
getHistory() {
return this.snapshots.map((s, i) => ({
index: i,
label: s.label,
timestamp: s.timestamp,
isCurrent: i === this.currentIndex
}));
}
}
// Usage
const timeTravel = new TimeTravel({ count: 0 });
timeTravel.save('Start');
timeTravel.update(draft => draft.count++, 'Increment 1');
timeTravel.update(draft => draft.count += 5, 'Add 5');
timeTravel.update(draft => draft.count *= 2, 'Double');
// Travel back in time
const state = timeTravel.travelTo(1); // Back to 'Increment 1'
console.log(state.count); // 1
// Continue from there
timeTravel.update(draft => draft.count += 10, 'Add 10 from branch');
// View timeline
console.log(timeTravel.getHistory());
Best Practices
Do's
// ✅ Mutate the draft directly
produce(state, draft => {
draft.user.name = 'New Name';
draft.items.push(newItem);
delete draft.oldProperty;
});
// ✅ Use native array methods
produce(state, draft => {
draft.items.sort((a, b) => a - b);
draft.items.reverse();
draft.items.fill(0, 0, 2);
});
// ✅ Nest produce calls when needed
produce(state, draft => {
draft.users = draft.users.map(user =>
produce(user, userDraft => {
userDraft.lastActive = Date.now();
})
);
});
// ✅ Return values from recipe
const result = produce(state, draft => {
draft.items.push(newItem);
return draft.items[draft.items.length - 1]; // Return the added item
});
// ✅ Use current() to access current state
import { current } from 'immer';
produce(state, draft => {
const currentValue = current(draft.someValue);
// currentValue is a plain object, not a proxy
});
Don'ts
// ❌ Don't return a new object from recipe
produce(state, draft => {
return { ...draft, newProp: true }; // Wrong!
});
// ✅ Do mutate draft directly
produce(state, draft => {
draft.newProp = true; // Correct!
});
// ❌ Don't use Object.assign on draft
produce(state, draft => {
Object.assign(draft, { newProp: true }); // Unnecessary
});
// ✅ Do direct assignment
produce(state, draft => {
draft.newProp = true; // Better
});
// ❌ Don't spread and return
produce(state, draft => {
const updated = { ...draft, count: draft.count + 1 };
return updated; // Defeats the purpose
});
// ✅ Do mutate
produce(state, draft => {
draft.count++; // Simple and clear
});
Performance Optimization
Structural Sharing
import { produce } from 'immer';
const state = {
user: { name: 'Alice', age: 30 },
settings: { theme: 'dark', lang: 'en' }
};
const newState = produce(state, draft => {
draft.user.age = 31;
});
// Immer uses structural sharing:
// - user object is copied (changed)
// - settings object is shared (unchanged)
console.log(state.settings === newState.settings); // true
console.log(state.user === newState.user); // false
Freezing
import { produce, setAutoFreeze } from 'immer';
// Auto-freeze is enabled in development by default
// Freeze prevents accidental mutations outside produce
setAutoFreeze(false); // Disable for performance in production
const state = { count: 0 };
const nextState = produce(state, draft => {
draft.count++;
});
// With auto-freeze enabled:
// Object.isFrozen(nextState); // true
// nextState.count = 5; // Throws error in strict mode
immerable Symbol
import { produce, immerable } from 'immer';
class User {
[immerable] = true; // Mark class as immer-compatible
constructor(name) {
this.name = name;
}
}
const user = new User('Alice');
const updated = produce(user, draft => {
draft.name = 'Bob';
});
console.log(updated instanceof User); // true
console.log(updated.name); // 'Bob'
Common Patterns
Form State Management
import { produce } from 'immer';
const initialState = {
values: {},
errors: {},
touched: {},
isSubmitting: false
};
const formReducer = produce((draft, action) => {
switch (action.type) {
case 'SET_FIELD':
draft.values[action.field] = action.value;
break;
case 'SET_ERROR':
draft.errors[action.field] = action.error;
break;
case 'SET_TOUCHED':
draft.touched[action.field] = true;
break;
case 'SET_SUBMITTING':
draft.isSubmitting = action.value;
break;
case 'RESET':
return initialState;
}
}, initialState);
Normalized State
import { produce } from 'immer';
const initialState = {
users: {}, // Map of id -> user
userIds: [] // Array of ids for ordering
};
const usersReducer = produce((draft, action) => {
switch (action.type) {
case 'ADD_USER':
draft.users[action.user.id] = action.user;
draft.userIds.push(action.user.id);
break;
case 'UPDATE_USER':
if (draft.users[action.id]) {
Object.assign(draft.users[action.id], action.updates);
}
break;
case 'REMOVE_USER':
delete draft.users[action.id];
draft.userIds = draft.userIds.filter(id => id !== action.id);
break;
}
}, initialState);
Optimistic Updates
import { produce } from 'immer';
async function optimisticUpdate(dispatch, newState, apiCall) {
// Optimistically update UI
dispatch({ type: 'UPDATE', state: newState });
try {
// Make API call
await apiCall();
// Success - confirm update
dispatch({ type: 'CONFIRM_UPDATE' });
} catch (error) {
// Failure - rollback
dispatch({ type: 'ROLLBACK_UPDATE' });
throw error;
}
}
// Reducer with rollback support
const reducer = produce((draft, action) => {
switch (action.type) {
case 'UPDATE':
draft.pendingChanges = action.state;
draft.previousState = { ...draft };
Object.assign(draft, action.state);
break;
case 'ROLLBACK_UPDATE':
Object.assign(draft, draft.previousState);
draft.pendingChanges = null;
break;
case 'CONFIRM_UPDATE':
draft.pendingChanges = null;
draft.previousState = null;
break;
}
});
Summary Table
| Feature | API | Use Case |
|---|---|---|
| Basic updates | produce(state, recipe) | Immutable state updates |
| Curried produce | produce(recipe) | Reusable updaters |
| Patches | enablePatches() | Track changes as diffs |
| Apply patches | applyPatch(state, patches) | Replay changes |
| Undo/Redo | Patches + history | Time travel debugging |
| Current value | current(draft) | Get plain object from proxy |
| Class support | [immerable] = true | Immer-compatible classes |
| Freezing | setAutoFreeze(false) | Production optimization |
Conclusion
Immer simplifies immutable state management by:
- Reducing boilerplate - No more nested spread operators
- Improving readability - Write natural mutation code
- Enabling advanced features - Patches, undo/redo, time travel
- Maintaining performance - Structural sharing for efficiency
Use Immer when:
- Managing complex nested state
- Implementing undo/redo functionality
- Working with Redux or similar state libraries
- Need patches for optimistic updates
Avoid Immer when:
- Simple state updates suffice
- Performance is critical and you need manual optimization
- Working with very large datasets frequently
Immer makes immutable updates feel mutable, reducing cognitive load while maintaining all the benefits of immutability.
#javascript #immer #state-management #immutable #redux #react #patterns