Immutable Data
Persistent collections, structural sharing, referential equality — the win and the cost.
Immutable Data
Immutable data is data that doesn't change once created — instead of mutating, you produce a new version with the change applied. The naive implementation costs a full copy on every update. The non-naive implementation (persistent data structures with structural sharing) costs almost nothing, scales to millions of items, and gives you constant-time change detection for free.
Analogy
Imagine a writer producing draft after draft of a novel. The naive approach is to retype the entire manuscript every time they tweak a chapter — workable for a short story, useless for War and Peace. The smart approach is to keep the unchanged chapters where they were and only retype the chapter that changed; the new "draft" is a thin index that mostly points at the old one. Anyone holding the old index still sees the old novel exactly. Two drafts, same paper for the chapters that didn't change. That's structural sharing — and it's what makes immutability practical at scale.
What "immutable" actually means
A value is immutable if you cannot modify it after creation. Operations that look like mutations actually return a new value:
const a = [1, 2, 3];
const b = a.concat([4]); // new array; a unchanged
a; // [1, 2, 3]
b; // [1, 2, 3, 4]
a === b; // false — different objects
concat always returns a new array. push mutates in place. The shape of the API matters: immutable APIs use concat, set, update, with. Mutating APIs use push, set (on Map!), splice, delete.
In TypeScript, readonly and Readonly<T> make mutation a compile-time error:
function rename(user: Readonly<User>): User {
// user.name = "Lin"; // TS error: cannot assign to readonly property
return { ...user, name: "Lin" };
}
Structural sharing
The naive way to "make immutable updates cheap" is to copy. That's O(n) every time and produces unfriendly garbage pressure. Persistent data structures share the unchanged parts.
For a 32-way hash array-mapped trie (HAMT, used by Immutable.js, Clojure, Scala), inserting one key into a 1-million-entry map:
- Copies roughly
log32(1,000,000) ≈ 4nodes. - Shares the rest pointer-equal with the previous version.
- Returns a new map in O(log n) time, in practice constant for any realistic n.
old map:
┌──root──┐
A B C D E F G
│ │ │ │ │ │
a1 b1 c1 d1 e1 f1 g1
new map after .set('e', e2):
┌──root'──┐
A B C D E' F G
│ │ │ │ │ │
a1 b1 c1 d1 e2 f1 g1
↑shared with old map (same pointers)
The leaves that didn't change in the path to e are reused. Only the modified path is fresh. oldMap === newMap is false (different roots), but oldMap.get('a') === newMap.get('a') is true — same node.
Referential equality is the win
Why React, Redux, and the broader functional-data ecosystem care so much: with immutable updates, you can detect change in O(1) — by reference comparison.
// React.memo skips render when props are pointer-equal to the previous ones.
const UserCard = React.memo(function UserCard({ user }: { user: User }) {
return <div>{user.name}</div>;
});
// In the parent:
const [users, setUsers] = useState(initialUsers);
// If you mutate (BAD):
users[0].name = "Lin";
setUsers(users); // useState bails — same array reference, no re-render
// If you replace immutably (GOOD):
setUsers(users.map((u, i) => i === 0 ? { ...u, name: "Lin" } : u));
// new array, only index 0 has a new object → only that card re-renders
Without immutability, "did this change?" is a deep walk of the data structure. With immutability, it's ===.
The cost: allocations
Every "mutation" allocates. For most apps this is fine — modern allocators are fast and most updates are small. For high-throughput hot paths (game loops, real-time signal processing, intensive numerics), the allocation pressure can hurt. The escape valves:
- Mutate inside a small scope, then freeze. Build the result with imperative code, return it as immutable to the rest of the system.
- Use mutable buffers with a
.toImmutable()step. Immutable.js haswithMutations; pyrsistent hasevolver. - Use Immer. Records the mutations to a draft and applies them to produce a new persistent value at the end.
import { produce } from "immer";
const next = produce(state, (draft) => {
for (const order of newOrders) {
draft.byId[order.id] = order;
draft.allIds.push(order.id);
}
});
// next is a new persistent object; state untouched.
Immer's authors call this "the best of both worlds" — write mutating code for ergonomics, get persistent semantics for correctness.
Object.freeze is shallow
JavaScript ships a built-in Object.freeze that prevents adding, removing, or modifying properties:
const obj = Object.freeze({ name: "Ada", tags: ["new"] });
obj.name = "Lin"; // silently ignored (strict: throws)
obj.tags.push("vip"); // ✓ works! tags is not frozen
Object.freeze is shallow. Nested objects are still mutable. To enforce deep immutability you write a deepFreeze function, switch to a persistent collection library, or rely on TypeScript's Readonly types and trust the build.
function deepFreeze<T extends object>(o: T): T {
for (const v of Object.values(o)) {
if (v && typeof v === "object") deepFreeze(v);
}
return Object.freeze(o);
}
Persistent collections in different stacks
| Stack | Built-in | Library |
|---|---|---|
| JavaScript | spread + Object.freeze (shallow) |
Immutable.js, Immer, mori |
| TypeScript | Readonly<T>, as const |
same as above |
| Python | tuple, frozenset, @dataclass(frozen=True) |
pyrsistent, immutables |
| Java | List.of, Map.of, records |
Vavr, Functional Java |
| Scala | immutable collections by default | built-in (Vector, Map) |
| Clojure | persistent collections by default | built-in |
| Rust | Arc<T> for shared, ownership-based |
im, rpds crates |
Clojure and Scala default to immutable collections; you have to opt into mutation. JavaScript and Python are the opposite. The defaults shape what your team will write.
Copy-on-write at the OS level
The same idea shows up in operating systems. fork() doesn't copy a process's pages — it marks them read-only and shared. The first time either parent or child writes, the OS then copies the page. Same trick: structural sharing at the page level.
Container layers, filesystem snapshots (ZFS, btrfs), copy-on-write file copies (APFS) all use this pattern. Immutable data structures are the application-level version of the same idea.
Common bugs
Mutating "immutable" data. Adding to a frozen array, mutating a deep nested object the React linter doesn't catch. The bug looks like "state didn't update" because referential equality reports no change.
Spread-shallow. { ...user } copies top-level fields but shares nested ones:
const copy = { ...user };
copy.tags.push("vip"); // mutates user.tags too!
// fix: { ...user, tags: [...user.tags, "vip"] }
useState(initialValue) with mutable initial. Mutating the initial value in development re-runs the function with a different starting state. Symptom: works in tests, breaks in dev StrictMode.
Performance assumptions wrong both directions. Sometimes immutable is faster (referential equality cuts the work). Sometimes it's slower (allocation churn). Profile before optimising.
Trying to make every value immutable. Diminishing returns — strings and numbers are already value types. The win is on collections (lists, maps, sets) and structured records.
Assuming === does a deep check. It doesn't. [1] === [1] is false. A new immutable update with the same logical value is still a new object.
Practical decisions
- For React + Redux state: immutable everywhere. Use Immer or Redux Toolkit (which uses Immer under the hood).
- For UI components: prefer
Readonly<Props>to clarify intent. - For data that holds for a request and dies: don't worry about it.
- For data that lives across versions / undo / replay: persistent collections pay for themselves.
- For numeric / hot-loop code: mutable arrays with controlled lifetime; freeze at the boundary.
- For language defaults that fight you (JS, Python): pick a strict-readonly lint rule and enforce it.