Immer Complete Guide | Immutable State Made Easy

Immer Complete Guide | Immutable State Made Easy

이 글의 핵심

Immer lets you work with immutable state by using mutable-style code. It's based on copy-on-write and is used by Redux Toolkit, React's useImmer, and many other libraries.

Introduction

Immer simplifies working with immutable state. Instead of manually creating copies with spread operators, you write intuitive “mutating” code, and Immer produces an immutable result.

Without Immer

const state = {
  user: {
    name: 'Alice',
    address: {
      city: 'New York',
      zipCode: '10001',
    },
  },
};

// Update nested value - hard to read!
const newState = {
  ...state,
  user: {
    ...state.user,
    address: {
      ...state.user.address,
      city: 'San Francisco',
    },
  },
};

With Immer

import { produce } from 'immer';

const newState = produce(state, draft => {
  draft.user.address.city = 'San Francisco';
});

// Much cleaner!

1. Installation

npm install immer

2. Basic Usage

import { produce } from 'immer';

const baseState = {
  count: 0,
  items: ['apple', 'banana'],
};

const nextState = produce(baseState, draft => {
  draft.count += 1;
  draft.items.push('orange');
});

console.log(baseState.count);      // 0 (unchanged)
console.log(nextState.count);      // 1
console.log(nextState.items);      // ['apple', 'banana', 'orange']

3. Array Operations

import { produce } from 'immer';

const todos = [
  { id: 1, text: 'Buy milk', done: false },
  { id: 2, text: 'Walk dog', done: false },
];

// Add item
const withNew = produce(todos, draft => {
  draft.push({ id: 3, text: 'Do laundry', done: false });
});

// Remove item
const withRemoved = produce(todos, draft => {
  const index = draft.findIndex(t => t.id === 2);
  draft.splice(index, 1);
});

// Update item
const withUpdated = produce(todos, draft => {
  const todo = draft.find(t => t.id === 1);
  todo.done = true;
});

// Filter (returns new array)
const withFiltered = produce(todos, draft => {
  return draft.filter(t => !t.done);
});

4. React Integration

import { useState } from 'react';
import { produce } from 'immer';

function TodoApp() {
  const [todos, setTodos] = useState([]);
  
  const addTodo = (text) => {
    setTodos(produce(draft => {
      draft.push({ id: Date.now(), text, done: false });
    }));
  };
  
  const toggleTodo = (id) => {
    setTodos(produce(draft => {
      const todo = draft.find(t => t.id === id);
      todo.done = !todo.done;
    }));
  };
  
  return (
    <div>
      {todos.map(todo => (
        <div key={todo.id} onClick={() => toggleTodo(todo.id)}>
          {todo.done ? '✓' : '○'} {todo.text}
        </div>
      ))}
    </div>
  );
}

useImmer Hook

npm install use-immer
import { useImmer } from 'use-immer';

function TodoApp() {
  const [todos, updateTodos] = useImmer([]);
  
  const addTodo = (text) => {
    updateTodos(draft => {
      draft.push({ id: Date.now(), text, done: false });
    });
  };
  
  const toggleTodo = (id) => {
    updateTodos(draft => {
      const todo = draft.find(t => t.id === id);
      todo.done = !todo.done;
    });
  };
  
  return <div>...</div>;
}

5. Curried Produce

import { produce } from 'immer';

// Create reusable updater
const addTodo = produce((draft, text) => {
  draft.push({ id: Date.now(), text, done: false });
});

const toggleTodo = produce((draft, id) => {
  const todo = draft.find(t => t.id === id);
  todo.done = !todo.done;
});

// Use
const todos = [];
const withNew = addTodo(todos, 'Buy milk');
const withToggled = toggleTodo(withNew, 1);

6. Patches

import { produceWithPatches, applyPatches } from 'immer';

const baseState = { count: 0, items: [] };

const [nextState, patches, inversePatches] = produceWithPatches(baseState, draft => {
  draft.count = 1;
  draft.items.push('apple');
});

console.log(patches);
// [
//   { op: 'replace', path: ['count'], value: 1 },
//   { op: 'add', path: ['items', 0], value: 'apple' }
// ]

// Apply patches to another state
const anotherState = applyPatches(baseState, patches);

// Undo with inverse patches
const undone = applyPatches(nextState, inversePatches);
console.log(undone); // Same as baseState

Use Case: Undo/Redo

function useUndoable(initialState) {
  const [state, setState] = useState(initialState);
  const [history, setHistory] = useState([]);
  const [index, setIndex] = useState(-1);
  
  const update = (updater) => {
    const [nextState, patches, inversePatches] = produceWithPatches(state, updater);
    
    setState(nextState);
    setHistory([...history.slice(0, index + 1), { patches, inversePatches }]);
    setIndex(index + 1);
  };
  
  const undo = () => {
    if (index < 0) return;
    const { inversePatches } = history[index];
    setState(applyPatches(state, inversePatches));
    setIndex(index - 1);
  };
  
  const redo = () => {
    if (index >= history.length - 1) return;
    const { patches } = history[index + 1];
    setState(applyPatches(state, patches));
    setIndex(index + 1);
  };
  
  return [state, update, undo, redo];
}

7. Return Values

import { produce } from 'immer';

const state = { count: 0 };

// Implicit return (modify draft)
const result1 = produce(state, draft => {
  draft.count = 1;
});
console.log(result1); // { count: 1 }

// Explicit return (replaces state)
const result2 = produce(state, draft => {
  return { count: 2, newProp: true };
});
console.log(result2); // { count: 2, newProp: true }

// Return undefined = no changes
const result3 = produce(state, draft => {
  if (draft.count > 10) {
    draft.count = 0;
  }
});
console.log(result3); // { count: 0 } (unchanged, same reference)

8. Redux Integration

Redux Toolkit uses Immer internally:

import { createSlice } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    // Write "mutating" code - Immer handles immutability!
    addTodo: (state, action) => {
      state.push({ id: Date.now(), text: action.payload, done: false });
    },
    toggleTodo: (state, action) => {
      const todo = state.find(t => t.id === action.payload);
      todo.done = !todo.done;
    },
  },
});

9. Performance

import { produce, enableMapSet } from 'immer';

// Enable ES2015 Maps and Sets (opt-in)
enableMapSet();

const state = new Map([
  ['user1', { name: 'Alice', age: 30 }],
  ['user2', { name: 'Bob', age: 25 }],
]);

const nextState = produce(state, draft => {
  draft.get('user1').age = 31;
});

// Efficient structural sharing
console.log(state.get('user2') === nextState.get('user2')); // true (reused)

10. Best Practices

1. Don’t Mix Return and Mutation

// Bad: mixing mutation and return
produce(state, draft => {
  draft.count = 1;
  return { count: 2 }; // Which one?
});

// Good: mutation only
produce(state, draft => {
  draft.count = 1;
});

// Good: return only
produce(state, draft => {
  return { count: 2 };
});

2. Use TypeScript

interface State {
  count: number;
  items: string[];
}

const state: State = { count: 0, items: [] };

const nextState = produce(state, (draft: Draft<State>) => {
  draft.count = 1;
  draft.items.push('apple');
  // TypeScript will catch errors!
});

3. Freeze in Development

import { setAutoFreeze } from 'immer';

// Enable in development (default)
setAutoFreeze(true);

// Disable in production for performance
if (process.env.NODE_ENV === 'production') {
  setAutoFreeze(false);
}

Summary

Immer simplifies immutable updates:

  • Mutable syntax for immutable results
  • Deep updates made easy
  • Structural sharing for performance
  • Patches for undo/redo
  • Used by Redux Toolkit

Key Takeaways:

  1. Use produce for readable updates
  2. Structural sharing maintains performance
  3. Patches enable undo/redo
  4. Works great with React hooks
  5. TypeScript support built-in

Next Steps:

Resources: