TL;DR
For immutable state updates in 2026: Immer is unbeatable when you have deeply nested state that changes frequently — the mutable-draft API eliminates spread-operator hell. structuredClone is a built-in browser/Node API that requires no library at all — use it for deep cloning when you just need a copy, not reactive updates. deep-clone libraries (klona, rfdc, lodash.clonedeep) fill the gap where you need fast cloning without Immer's complexity.
Key Takeaways
- Immer: ~10M weekly downloads — produce API, structural sharing, used in Redux Toolkit and Zustand
- structuredClone: Native browser/Node API (no npm install) — simple deep clone, limited type support
- klona: ~1M weekly downloads — tiny, fastest deep clone library with type-safe cloning
- Immer uses structural sharing — only changed nodes are new references
- structuredClone serializes through the structured clone algorithm (no functions, no classes)
- Use Immer for state management; use structuredClone/klona for copying data
When You Need Immutable Updates
The core problem with mutable JavaScript:
// ❌ Mutating state directly — breaks React's reference equality check:
const state = { user: { name: "Royce", preferences: { theme: "dark" } } }
state.user.preferences.theme = "light" // Same reference — React won't re-render
// ✅ Immutable update — returns new object with new references:
const newState = {
...state,
user: {
...state.user,
preferences: {
...state.user.preferences,
theme: "light"
}
}
}
// This is verbose. Immer makes it readable.
Immer
Immer lets you write mutating code that produces immutable results:
import { produce, createDraft, finishDraft, enableMapSet } from "immer"
// Basic produce — synchronous:
const baseState = {
packages: [
{ name: "react", version: "18.2.0", starred: false },
{ name: "vue", version: "3.4.0", starred: true },
],
filters: { starred: false, minDownloads: 0 },
selectedCount: 0,
}
// Update deeply nested state with mutation syntax:
const nextState = produce(baseState, (draft) => {
// Find and update:
const pkg = draft.packages.find(p => p.name === "react")
if (pkg) {
pkg.starred = true // ← Mutating draft is safe!
}
// Push to array:
draft.packages.push({ name: "solid", version: "1.8.0", starred: false })
// Delete from object:
delete draft.filters.starred
// Derived updates:
draft.selectedCount = draft.packages.filter(p => p.starred).length
})
// baseState is unchanged:
console.log(baseState.packages[0].starred) // false
console.log(nextState.packages[0].starred) // true
// Structural sharing: unchanged branches share references:
console.log(nextState.filters === baseState.filters) // false (modified)
console.log(nextState.packages[1] === baseState.packages[1]) // true! (unchanged vue)
Immer with React's useState:
import { produce } from "immer"
function PackageManager() {
const [state, setState] = useState(initialState)
const starPackage = (name: string) => {
setState(produce(draft => {
const pkg = draft.packages.find(p => p.name === name)
if (pkg) pkg.starred = !pkg.starred
draft.selectedCount = draft.packages.filter(p => p.starred).length
}))
}
const updateFilter = (key: string, value: number) => {
setState(produce(draft => {
draft.filters[key] = value
}))
}
// With useImmer hook (convenience wrapper):
// import { useImmer } from "use-immer"
// const [state, updateState] = useImmer(initialState)
// updateState(draft => { draft.filters.minDownloads = 1000000 })
}
Immer with Map and Set:
import { enableMapSet, produce } from "immer"
// Maps and Sets require explicit opt-in:
enableMapSet()
const state = {
selectedPackages: new Set(["react", "vue"]),
packageMeta: new Map([["react", { featured: true }]]),
}
const nextState = produce(state, (draft) => {
draft.selectedPackages.add("solid")
draft.selectedPackages.delete("vue")
draft.packageMeta.set("solid", { featured: false })
})
Immer's produce is zero-cost when nothing changes:
const result = produce(state, (draft) => {
// Make no changes
})
console.log(result === state) // true — same reference returned
Immer in Redux Toolkit:
Redux Toolkit uses Immer internally — every createSlice reducer runs through Immer:
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
const packagesSlice = createSlice({
name: "packages",
initialState,
reducers: {
starPackage: (state, action: PayloadAction<string>) => {
// This mutating code is safe — RTK wraps it with Immer:
const pkg = state.packages.find(p => p.name === action.payload)
if (pkg) pkg.starred = !pkg.starred
},
addFilter: (state, action: PayloadAction<Filter>) => {
state.activeFilters.push(action.payload)
},
},
})
structuredClone
structuredClone is a native browser and Node.js API (available since Node 17, all modern browsers):
// No import needed — it's a global:
const original = {
name: "react",
dependencies: ["react-dom"],
metadata: { nested: { deep: true } },
date: new Date("2024-01-01"), // ✅ structuredClone handles Date
pattern: /react/gi, // ✅ RegExp supported
}
const clone = structuredClone(original)
clone.metadata.nested.deep = false
console.log(original.metadata.nested.deep) // true — independent clone
console.log(clone.metadata.nested.deep) // false
// structuredClone supports these types:
// Primitives, Array, Object, Date, RegExp, Map, Set, ArrayBuffer,
// TypedArrays, DataView, ImageBitmap, Blob, File, FileList, Error types
// structuredClone does NOT support:
const invalid = {
fn: () => console.log("hello"), // ❌ Functions are dropped
symbol: Symbol("key"), // ❌ Symbols are dropped
undefined: undefined, // ✅ Actually works
}
class CustomClass {
value = 42
method() { return this.value }
}
const instance = new CustomClass()
const cloned = structuredClone(instance)
// cloned is a plain object — class prototype is lost:
console.log(cloned instanceof CustomClass) // false
console.log(cloned.method) // undefined
Performance:
structuredClone uses the browser's native serialization algorithm — it's implemented in C++ and is very fast, but serializes through a structured format which has overhead:
// For simple objects — structuredClone is plenty fast:
const data = { packages: Array.from({ length: 1000 }, (_, i) => ({ name: `pkg-${i}`, downloads: i * 1000 })) }
console.time("structuredClone")
const clone1 = structuredClone(data) // ~1-3ms for 1000 objects
console.timeEnd("structuredClone")
When to use structuredClone:
- Copying configuration objects before mutation
- Creating history snapshots in undo/redo
- Web Workers (postMessage clones data — understanding structuredClone helps)
- Server-side data transformation before returning
Deep Clone Libraries
When structuredClone isn't fast enough or you need class instance preservation:
klona
klona — fastest deep clone library with multiple export paths:
import { klona } from "klona" // Full clone (supports most types)
import { klona } from "klona/json" // JSON-safe only, fastest
import { klona } from "klona/lite" // Objects + Arrays only
const state = {
packages: [{ name: "react", tags: new Set(["ui", "components"]) }],
cache: new Map([["react", { downloads: 25000000 }]]),
}
const clone = klona(state) // Handles Map, Set, Date, RegExp, TypedArrays
clone.packages[0].name = "vue"
console.log(state.packages[0].name) // "react" — independent
rfdc (Really Fast Deep Clone)
const clone = require("rfdc")()
// rfdc is the fastest general-purpose deep clone:
const deepCopy = clone(largeDataStructure)
lodash.cloneDeep
import cloneDeep from "lodash.clonedeep"
// Most feature-complete, slowest:
const copy = cloneDeep(value)
// Handles circular references, class instances (preserves prototype), etc.
Performance Comparison
Cloning 1,000 objects with nested arrays (approximate):
| Method | Speed | Notes |
|---|---|---|
| JSON.parse(JSON.stringify()) | ~2ms | Fastest, loses Date/RegExp/undefined |
| rfdc | ~1.5ms | Fastest library |
| klona/json | ~2ms | Near-JSON speed, JSON-safe only |
| klona | ~3ms | Full-featured |
| structuredClone | ~4ms | Native, handles most types |
| Immer produce | ~0.5ms* | *Structural sharing, not full copy |
| lodash.cloneDeep | ~8ms | Slowest, most compatible |
*Immer doesn't clone the entire state — it only copies modified nodes (structural sharing), making it faster than deep clone for large state.
Production Performance and Memory Implications
Immer's structural sharing is the performance characteristic that makes it suitable for large application state. When you call produce() and modify only a deeply nested node, Immer copies only the nodes on the path from root to the changed node — all unchanged branches are shared references. For a state object with 1,000 items where you update one item's field, Immer creates approximately three new objects (root → array → updated item) rather than copying all 1,000 items. This matters critically for React rendering performance: memo() and useMemo() use reference equality, so structural sharing means unchanged subtrees don't trigger re-renders. The React Compiler (stable in React 19) makes this even more important, as it relies on reference stability to determine what to memoize at the component level. Teams measuring memory allocations in production should note that Immer's Proxy-based draft approach creates Proxy objects for every node traversed during the produce callback — for very hot paths called thousands of times per second, consider whether the draft model introduces GC pressure.
TypeScript Integration and Type Safety
All three approaches have strong TypeScript stories, but they differ in how types propagate. Immer infers the type of the produced value from the base state type — if your state is AppState, the draft is Draft<AppState> (which makes all readonly fields writable for mutation purposes) and the produced value is AppState. This works seamlessly with Redux Toolkit's PayloadAction<T> pattern and Zustand's set() function. structuredClone returns T for structuredClone(value: T), preserving your types exactly, though TypeScript cannot enforce that the cloned object is a different reference. The deep clone libraries (klona, rfdc) are similarly typed. One practical difference: if your state contains class instances with custom methods, Immer preserves class identity through drafts, while structuredClone strips class prototypes — a meaningful distinction for teams using domain models expressed as classes rather than plain objects.
Migration from Mutable to Immutable Patterns
Teams migrating an existing codebase from mutable state management to immutable patterns face different challenges depending on the mutation style. Object mutation detected by React is the most common issue: code like state.items.push(newItem) appears to work in development with React's Strict Mode double-rendering masking the problem, then fails silently in production when memo() comparisons see no reference change. Immer makes this migration low-risk — you introduce produce() around the existing mutation code, and the draft proxy handles the conversion to immutable updates while the internal mutation logic remains unchanged. This means you can migrate incrementally, file by file, without rewriting all state update logic at once. The use-immer hook provides useImmer() as a drop-in replacement for useState() where the setter accepts an Immer recipe function, making component-level migration straightforward. Teams should enable Immer's development-only freeze mode (setAutoFreeze(true)) during migration to detect any code paths that still mutate state directly.
Ecosystem Context and Framework Integration
Immer's adoption across the React ecosystem is unusually broad. Redux Toolkit wraps every createSlice reducer in Immer automatically, Zustand's immer middleware enables the same pattern for Zustand stores, and XState's model update functions can also leverage Immer. This means developers who learn the Immer draft pattern once can apply it across every major state management library in the ecosystem. The use-immer package provides useImmer and useImmerReducer hooks that integrate directly with React's component model. For server-side code — API handlers, data transformation pipelines, background jobs — structuredClone and the faster clone libraries are more appropriate since there's no reactive rendering involved and the overhead of Proxy-based tracking is unnecessary for one-shot data transformations.
Edge Cases and Gotchas
Several behaviors trip up developers new to these libraries in production. Immer's produce throws if you both mutate the draft and return a value — you must choose one approach or the other. Circular references cause structuredClone to work correctly (it handles them natively) but break JSON.parse/JSON.stringify entirely. Class instances passed to structuredClone lose their prototype chain, which means instanceof checks fail on the cloned object; this commonly causes bugs in codebases that use Error subclasses or domain model classes. The enableMapSet() call in Immer must be invoked before any code that creates draft proxies over Maps or Sets — placing it in your app's initialization file rather than co-locating with usage prevents subtle ordering bugs in production builds where module evaluation order differs from development. For rfdc, the default configuration does not handle circular references; passing { circles: true } enables detection at the cost of approximately 30% performance overhead.
When to Use Each
Use Immer if:
- Managing application state with nested updates (state management context)
- Integrated with Redux Toolkit (it's already there — use it)
- You need structural sharing (memory efficiency for large state)
- Updates are frequent (Immer's draft model is the most ergonomic)
Use structuredClone if:
- You need a one-time deep copy of data (no state management context)
- The data is JSON-safe or uses Dates, Maps, Sets, RegExp
- No library dependency is acceptable
- You're working with Web Workers (the mental model is the same)
Use klona/rfdc if:
- Maximum clone performance is critical
- structuredClone's overhead is measurable in your profiler
- You need a tiny bundle (klona is ~400 bytes)
Use JSON.parse(JSON.stringify()) if:
- Data is guaranteed to be JSON-safe (no functions, Dates, undefined)
- You understand the limitations (and they don't apply to your case)
- Maximum compatibility (works everywhere, no dependencies)
Never use in production:
Object.assign({}, obj)for deep clone — it's shallow only{...obj}spread for deep clone — also shallow only
Methodology
Download data from npm registry (weekly average, February 2026). Performance benchmarks are approximate based on klona/rfdc documentation benchmarks and community measurements. Comparison covers Immer 10.x, klona 4.x, and Node.js 22 structuredClone.
Compare utility library packages on PkgPulse →
See also: AVA vs Jest and @preact/signals vs React useState vs Jotai, Fuse.js vs FlexSearch vs Orama: Search 2026.