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.
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.
๐ง 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: