Immutable Data Patterns

TL;DR / Immutable data patterns create new objects instead of modifying existing ones, making state changes explicit and enabling reliable change detection.

How It Works

 MUTABLE (danger)                  IMMUTABLE (safe)

 ┌──────────────┐                  ┌────────────────┐
 │  obj.x = 5   │                  │    newObj =    │
 └──────────────┘                  │ {...obj, x:5}  │
         │                         └────────────────┘
         │                                  │
         └┐            obj !== newObj       │
          │                                 │
          ↓                                 ↓
 ┌─────────────────┐               ┌─────────────────┐
 │    Same ref     │               │     New ref     │
 │ Silent mutation │               │ Change detected │
 └─────────────────┘               └─────────────────┘

Edit diagram

Immutable data patterns enforce a simple rule: never modify data in place. Instead of changing a property on an existing object, you create a new object with the desired change while leaving the original untouched. This makes every state transition explicit — you can always compare old and new by reference and know whether something changed.

In JavaScript, this matters because objects and arrays are reference types. When you write const b = a and then mutate b.x = 5, you have also changed a.x because both variables point to the same memory. This shared-reference mutation is the root cause of an entire class of bugs: stale UI, missed re-renders, broken undo/redo, and race conditions in concurrent code.

The Spread Pattern

The most common immutable pattern in JavaScript is the spread operator:

const next = { ...prev, name: "updated" };

This creates a shallow copy — a new object with the same top-level properties, except for the ones you override. For nested objects, you must spread at every level:

const next = {
  ...state,
  user: { ...state.user, name: "updated" }
};

This is verbose but explicit. Each level of nesting requires its own spread, which means you can see exactly which parts of the tree are being changed. The downside is that deeply nested updates become unwieldy and error-prone.

Copy-on-Write with Immer

Immer solves the nesting problem by using ES6 Proxies. You write what looks like mutation inside a produce callback, and Immer intercepts each write to build an immutable update behind the scenes:

const next = produce(state, draft => {
  draft.user.name = "updated";
});

Under the hood, Immer only copies the objects along the path you modified. Everything else retains its reference — this is structural sharing. The result is identical to hand-written spreads but far more readable for deep updates.

Why React Depends on Immutability

React's rendering model is built on reference comparison. When you call setState, React compares old and new state with ===. If the reference is the same, React assumes nothing changed and skips re-rendering. If you mutate in place, the reference never changes, and React never sees the update.

This is why state.items.push(newItem) followed by setState(state) does nothing visible in React — you've handed back the same reference. You need setState({ ...state, items: [...state.items, newItem] }) to create a new reference that triggers a re-render.

The same principle applies to useEffect dependencies, useMemo, useCallback, and React.memo. All of these rely on reference checks. Immutable updates make these optimizations work correctly; mutations silently break them.

Freezing for Safety

In development, you can use Object.freeze() to make mutation throw errors rather than silently succeed. Immer does this automatically in development mode. Libraries like deep-freeze recursively freeze entire object trees.

Freezing is a development-only tool — it adds overhead and should be disabled in production. But during development, it catches an important category of bugs: accidentally mutating state that you thought you were treating immutably.

Performance Considerations

A common objection to immutability is performance: creating new objects on every update sounds expensive. In practice, the cost is negligible for typical frontend state. A shallow copy of an object with 50 keys takes microseconds. The real performance benefit comes from what immutability enables — cheap reference checks that let React skip entire subtree re-renders.

Where immutability can become expensive is in tight loops processing thousands of items. In those cases, consider using mutable local variables within a function (local mutation is fine because it is not observable externally) and returning an immutable result.

Gotchas

  • Array methods that mutatepush, pop, splice, sort, reverse all modify the array in place. Use concat, slice, filter, map, toSorted, and toReversed (ES2023) for immutable alternatives.
  • Shallow copy is not deep copy. Spreading only copies one level. If you spread a parent but forget to spread a nested child, you are sharing a mutable reference to that child between old and new state.
  • const does not mean immutable. const prevents reassignment of the variable binding, but the object it points to is still fully mutable. const obj = {}; obj.x = 1 is perfectly valid JavaScript.
  • Immer's draft is only valid inside produce. Storing or returning the draft proxy outside the callback causes obscure errors because the proxy has been revoked.
  • Normalization reduces the cost of immutable updates. Flat, normalized state (keyed by ID) requires fewer nested spreads than deeply nested trees. This is why Redux recommends normalized state shape.