Skip to main content

Immer vs structuredClone vs deep-clone: Immutable Updates in JavaScript (2026)

·PkgPulse Team

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

MethodSpeedNotes
JSON.parse(JSON.stringify())~2msFastest, loses Date/RegExp/undefined
rfdc~1.5msFastest library
klona/json~2msNear-JSON speed, JSON-safe only
klona~3msFull-featured
structuredClone~4msNative, handles most types
Immer produce~0.5ms**Structural sharing, not full copy
lodash.cloneDeep~8msSlowest, 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.

Compare utility library packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.