Immer vs structuredClone vs deep-clone: Immutable Updates in JavaScript (2026)
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.
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.