Asher Cohen
Back to posts

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

FeatureAPIUse Case
Basic updatesproduce(state, recipe)Immutable state updates
Curried produceproduce(recipe)Reusable updaters
PatchesenablePatches()Track changes as diffs
Apply patchesapplyPatch(state, patches)Replay changes
Undo/RedoPatches + historyTime travel debugging
Current valuecurrent(draft)Get plain object from proxy
Class support[immerable] = trueImmer-compatible classes
FreezingsetAutoFreeze(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