Immutability โ€” State as a Value

Lesson 5: Immutability โ€” State as a Value

Most bugs in software trace back to one root cause: shared mutable state. Two pieces of code point at the same data, one changes it, the other breaks โ€” and you get to spend the afternoon finding out which one. FP's answer is radical: don't mutate anything. Ever.

The Aliasing Problem

const cart = [1, 2, 3];
const checkoutCopy = cart;   // same array, two names

checkoutCopy.push(4);        // "copy" mutates the original

console.log(cart);           // [1, 2, 3, 4] โ€” surprise!
console.log(checkoutCopy);   // [1, 2, 3, 4]

The variable named cart didn't change โ€” but the array it points to did, so cart "changed" anyway. In a big codebase, the function that did the damage can be hundreds of calls away from the code that suffered it. That's the aliasing trap.

MUTABLE โ€” two names, one array cart checkoutCopy [1, 2, 3, 4] push(4) hit both names IMMUTABLE โ€” each update makes a fresh value

In FP you never mutate โ€” you produce a new value:

const cart = [1, 2, 3];
const checkoutCopy = [...cart, 4];   // brand new array

console.log(cart);          // [1, 2, 3] โ€” untouched
console.log(checkoutCopy);  // [1, 2, 3, 4]

The same idea applies to objects: { ...user, name: "New" } creates a new object instead of editing the old one. The old value stays valid for anyone still holding it.

Structural Sharing

"Copying everything" sounds expensive โ€” and naive copying is. That's why FP languages and libraries use persistent data structures with structural sharing: the new version shares the unchanged parts with the old one, so an update costs O(log n) instead of O(n). Examples: Clojure's and Elixir's built-in collections, Haskell's containers, JavaScript's Immutable.js and Immer.

State as a Value: The Reducer Pattern

Instead of mutating state, FP treats state as a value that flows through the program. The canonical pattern is the reducer (familiar from React/Redux):

function reducer(state, action) {
  switch (action.type) {
    case "ADD_ITEM":
      return { ...state, items: [...state.items, action.item] };
    case "REMOVE_ITEM":
      return { ...state, items: state.items.filter(i => i.id !== action.id) };
    case "CLEAR":
      return { ...state, items: [] };
    default:
      return state;
  }
}

// usage: each step gets a NEW state, never touches the old one
let state = { items: [] };
state = reducer(state, { type: "ADD_ITEM", item: { id: 1 } });
state = reducer(state, { type: "ADD_ITEM", item: { id: 2 } });
state = reducer(state, { type: "REMOVE_ITEM", id: 1 });

One pure function: given the current state and an action, it returns the next state. Nothing is mutated โ€” which buys you undo/redo, time-travel debugging, and replayable behavior for free, because every past state still exists as a value.

Immutability in the Real World

  • The ledger analogy: banks never erase a transaction โ€” they append. Immutable data is a ledger: the history is always intact and auditable.
  • Thread safety: no locks needed if nothing shares mutable state. This is why FP scales to multicore and distributed systems.
  • Testing: a reducer is a pure function of (state, action) โ€” call it, assert on the returned state, done.
Not zero mutation โ€” contained mutation: Real programs mutate at the edges (databases, caches, DOM). The FP discipline is: keep the places that mutate few, obvious, and quarantined from your logic. When mutation is unavoidable, make it loud.

๐Ÿง  Knowledge Check

1. What is the "aliasing" problem?

2. How does const next = [...items, newItem] differ from items.push(newItem)?

3. Persistent data structures with structural sharing make updates:

Further Reading