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:
- Use produce for readable updates
- Structural sharing maintains performance
- Patches enable undo/redo
- Works great with React hooks
- TypeScript support built-in
Next Steps:
Resources: