<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/immer-vs-structuredclone-vs-deep-clone-immutable-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/immer-vs-structuredclone-vs-deep-clone-immutable-2026/raw.md -->
<!-- Source path: content/guides/immer-vs-structuredclone-vs-deep-clone-immutable-2026.mdx -->

---
og_image: "/images/guides/immer-vs-structuredclone-vs-deep-clone-immutable-2026.webp"
title: "Immer vs structuredClone vs deep-clone 2026"
description: "Compare Immer, structuredClone, and deep-clone libraries for immutable state updates in JavaScript and TypeScript. Performance, API ergonomics, and when to."
date: "2026-03-09"
author: "PkgPulse Team"
tags: ["javascript", "typescript", "state-management", "performance"]
---

## 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:

```typescript
// ❌ 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](https://immerjs.github.io/immer/) lets you write mutating code that produces immutable results:

```typescript
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:**

```typescript
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:**

```typescript
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:**

```typescript
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:

```typescript
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):

```typescript
// 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:

```typescript
// 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](https://github.com/lukeed/klona) — fastest deep clone library with multiple export paths:

```typescript
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)

```typescript
const clone = require("rfdc")()

// rfdc is the fastest general-purpose deep clone:
const deepCopy = clone(largeDataStructure)
```

### lodash.cloneDeep

```typescript
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 →](https://www.pkgpulse.com/compare/immer-vs-lodash)*

*See also: [AVA vs Jest](/compare/ava-vs-jest) and [@preact/signals vs React useState vs Jotai](/guides/preact-signals-vs-react-usestate-vs-jotai-fine-grained-2026), [Fuse.js vs FlexSearch vs Orama: Search 2026](/guides/fusejs-vs-flexsearch-vs-orama-client-side-search-2026).*
