Immutable State the Easy Way: Understanding Immer
Updating nested state in JavaScript without mutation is tedious. You spread objects at every level, track which references changed, and hope you didn’t accidentally mutate something along the way. For a simple nested update, you might write five lines of careful copying logic.
Immer solves this problem with a different approach: write code that looks like mutation, but produces immutable state automatically. This article explains how Immer’s proxy-based immutability works, why Redux Toolkit uses it internally, and what practical caveats you should know before adopting it.
Key Takeaways
- Immer lets you write mutation-style code that produces immutable state through its
producefunction and proxy-based drafts - Structural sharing ensures only modified branches get new references, preserving performance for React and Redux re-render checks
- Redux Toolkit uses Immer internally, so
createSlicereducers automatically handle immutability without extra imports - Watch for common pitfalls: don’t reassign the draft itself, avoid mixing draft mutations with return values, and be cautious with class instances
How Immer Produces Immutable State
Immer’s core API is the produce function. You pass it your current state and a “recipe” function. Inside that recipe, you receive a draft—a proxy wrapping your original state. You modify the draft using normal JavaScript mutations. When the recipe finishes, Immer generates a new immutable state reflecting your changes.
import { produce } from "immer"
const baseState = {
user: { name: "Alice", settings: { theme: "dark" } }
}
const nextState = produce(baseState, draft => {
draft.user.settings.theme = "light"
})
// baseState.user.settings.theme is still "dark"
// nextState.user.settings.theme is "light"
The mental model is straightforward: pretend you’re mutating, but Immer handles the immutable updates behind the scenes.
Proxy-Based Immutability and Structural Sharing
Immer uses JavaScript Proxy objects to intercept your reads and writes on the draft. When you access a nested property, Immer lazily creates a proxy for that path. When you write to a property, Immer marks that node (and its ancestors) as modified and creates shallow copies only where needed.
This copy-on-write approach enables structural sharing. Unchanged portions of your state tree remain the same object references. Only modified branches get new references. This matters for React and Redux because reference equality checks determine whether components re-render.
Modern Immer (v10+) requires native Proxy support—there’s no ES5 fallback. This is fine for current browsers and Node.js but worth noting if you target unusual environments.
Redux Toolkit Immer Integration
If you use Redux Toolkit, you’re already using Immer. RTK’s createSlice wraps your reducer logic with produce automatically. You write “mutating” code in case reducers, and RTK handles immutability:
import { createSlice } from "@reduxjs/toolkit"
const todosSlice = createSlice({
name: "todos",
initialState: [],
reducers: {
addTodo: (state, action) => {
state.push(action.payload) // Looks like mutation, but it's safe
},
toggleTodo: (state, action) => {
const todo = state.find(t => t.id === action.payload)
if (todo) todo.completed = !todo.completed
}
}
})
This Redux Toolkit Immer integration eliminates the spread-operator boilerplate that made vanilla Redux reducers verbose. You don’t need to import produce separately—RTK configures it for you.
Discover how at OpenReplay.com.
Auto-Freezing and Iteration Behavior
Immer auto-freezes produced state by default (using Object.freeze). This catches accidental mutations during development but adds overhead. You can disable it in production via configuration if profiling shows it matters:
import { setAutoFreeze } from "immer"
setAutoFreeze(false) // Disable in production for performance
Modern Immer defaults to loose iteration for performance: only enumerable string keys are iterated over in drafts. This is usually what you want for application state, but it means symbol keys and non-enumerable properties are skipped unless you explicitly enable strict iteration. This mainly matters in edge cases or low-level data manipulation.
Practical Caveats for JavaScript Immutable Updates
Several pitfalls trip up developers new to Immer:
Don’t reassign the draft itself. Modifying draft.property works. Reassigning draft = newValue does nothing useful because you’re only changing the local parameter binding.
Returning values matters. If your recipe returns a value, Immer uses that as the new state instead of the modified draft. Returning undefined is treated the same as returning nothing, but mixing draft mutations and explicit returns is a common source of bugs—use one approach or the other.
Classes and exotic objects need care. Immer works best with plain objects, arrays, Maps, and Sets. Class instances aren’t automatically proxied correctly. You may need to mark them as immutable or handle them separately (as outlined in the official pitfalls documentation).
Performance isn’t free. Immer is suitable for typical UI state—forms, lists, user preferences. For hot loops processing thousands of items per frame, measure before assuming Immer adds no overhead. The proxy machinery has a cost.
Tree-shaped state works best. Immer assumes your state is a tree. Circular references or shared object references across branches can produce unexpected results.
Conclusion
Immer shines for moderately complex, nested state updates—exactly what you encounter in React components and Redux reducers. It eliminates boilerplate, catches accidental mutations, and integrates seamlessly with Redux Toolkit.
For simple flat state, native spread operators work fine. For performance-critical data processing, benchmark first. But for typical frontend state management, Immer’s proxy-based approach offers the best balance of developer experience and correctness.
FAQs
Yes. You can wrap your state updates with produce directly, or use Immer's useImmer hook from the use-immer package. This gives you the same mutation-style syntax for local component state that Redux Toolkit provides for global state.
Immer has excellent TypeScript support. The produce function infers types automatically from your base state, and draft objects maintain proper typing. You get full autocomplete and type checking while writing mutation-style code.
Immer supports Maps and Sets natively. You can use standard Map and Set methods like set, delete, and add directly on drafts in modern versions without any additional configuration.
Only if profiling shows it causes performance issues. Auto-freeze helps catch bugs by throwing errors on accidental mutations. For most applications the overhead is negligible, but high-frequency updates may benefit from disabling it via setAutoFreeze false.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.