Pure Functions and Immutable State
Pure Functions and Immutable State
The two foundations The Elm Architecture (and most modern reactive frameworks) sits on. Without them, the pattern doesn't have its useful properties — no easy testing, no time-travel, no predictable rendering.
These ideas pre-date Elm by decades. Functional programming languages (Haskell, OCaml, Lisp) have made these defaults since the '70s and '80s. Mainstream programming caught up gradually; the React/Redux era (2015+) was when "pure functions" and "immutable state" became unremarkable in everyday frontend code.
Pure functions
A function is pure if:
- It always returns the same output for the same inputs.
- It has no observable side effects.
add : Int -> Int -> Int
add a b = a + b
Pure. Same inputs always give same output. No I/O.
function add(a, b) {
console.log("adding!"); // side effect
return a + b;
}
Impure. The console.log is observable.
let counter = 0;
function nextId() {
counter++; // mutation
return counter;
}
Impure. Mutates external state. Different output for same input (no input!).
function fetchUser(id) {
return fetch(`/api/users/${id}`); // network call
}
Impure. Network is a side effect. Output depends on the network's state, not just id.
Why pure functions matter
Three properties:
1. Substitutable
You can replace a call to a pure function with its return value, anywhere, without changing behaviour. add(2, 3) ≡ 5. This is the referential transparency principle. Compilers can use it to optimise. Tests can use it to verify.
2. Testable
Pass inputs, assert outputs. No mocks. No setup. No teardown.
#[test]
fn test_update_increments_counter() {
let state = AppState { count: 5 };
let (new_state, cmd) = update(state, Msg::Increment);
assert_eq!(new_state.count, 6);
}
Compare to testing impure code: mock the database, mock the time, mock the random source, simulate the event listener, assert the side effect happened. Brittle and slow.
3. Composable
Pure functions compose without surprises. f(g(x)) always means "apply g, then f to the result." Order of evaluation is the only sequencing concern. Effects, in contrast, can interfere with each other in subtle ways (e.g., two functions both writing to a shared cache).
Immutable state
State is immutable if it cannot be modified after creation. Updates produce new state values; the old state is unchanged.
// Mutable
const state = { count: 0 };
state.count = 1; // mutates the object
// Immutable
const state = { count: 0 };
const newState = { ...state, count: 1 }; // new object, old preserved
Functional languages typically make immutable the default; mutability requires explicit annotation. Mainstream languages (JS, Python, C++) default to mutable; immutable patterns are an option.
Why immutable state matters
1. No spooky action at a distance
If function A receives a state object and function B (running concurrently or later) doesn't modify it, A can be sure its observations are stable. With mutable state, A's reads of state.x might disagree with each other if B is running between them.
2. Easy diffing and equality
prevState === nextState reliably tells you if state changed. React uses this for re-render decisions: "if the state object is the same reference, skip re-rendering."
3. Time-travel debugging
Save a sequence of state values; replay them to inspect history. Possible with immutable state because the values don't change after capture. Mutable state undermines this — the value you saved might mutate before you look at it.
4. Safer concurrency
Multiple threads can read immutable state without locks. Mutations require either a new value (immutable update) or explicit synchronisation. Locks become rare.
How updates work without mutation
Naive immutable updates copy entire data structures, which is wasteful for large objects. Real-world implementations use persistent data structures — internal trie/tree representations that share unchanged substructures between versions.
Old map: { a: 1, b: 2, c: 3 }
New map: { a: 1, b: 2, c: 4 }
Internally, both share the {a, b} part; only the c branch is rewritten. Updates are O(log n) instead of O(n).
Libraries: Immutable.js (JS), the im crate (Rust), Clojure's persistent collections, Scala's collections, Elixir's collections — all built on this idea.
For app-level state in mxr/lazydap, the structures are small enough that even a full copy per update is cheap (microseconds). No need for clever sharing; clone-on-write is fine.
How this enables The Elm Architecture
update : (Model, Msg) -> (Model, Cmd) is a pure function with immutable inputs and outputs. From this:
- Tests are trivial (input → output assertions).
- Replays are trivial (re-run update over a sequence of msgs).
- State diffing is trivial (
prevModel != nextModeltriggers re-render). - No race conditions in update (no shared mutable state).
If update were allowed to mutate, none of this works. The discipline is the foundation.
Pragmatic Rust note
Rust doesn't have language-level immutability the way Elm does. You can mutate &mut references freely. The discipline is convention: in TEA-shaped code, don't mutate state outside the update function. No callbacks that mutate. No &mut self methods on the model that mutate fields directly.
update typically takes ownership of state and returns owned new state:
pub fn update(state: AppState, msg: Msg) -> (AppState, Cmd) { ... }
Or in pragmatic compromise (which Lazydap uses), takes &mut state and mutates in-place but conceptually treats the state as immutable across calls — the discipline is "all mutations happen here, nowhere else." Less pure but cheaper to write.
Common pitfalls
- Hidden mutation. Setting up an event listener that mutates the model from outside
update. This breaks the discipline; race conditions creep back. - Shared mutable refs. Storing
Rc<RefCell<X>>in the model. The interior mutability hides updates from React/Elm-style diffing. - Mutating return values. Even if
updateis pure, if you mutate its return value somewhere, the system loses its guarantees.
When pure + immutable isn't worth it
- Hot loops in numerical code. Allocating new arrays per iteration kills performance. Use mutable buffers; isolate them.
- Stream processing where state churns at high rate. Persistent structures get pricey.
- Interop with mutable APIs (DOM, GPU, file systems). Some impurity is unavoidable; isolate it behind pure-looking interfaces.
For UI state, agent state, debugger state — all the things mxr/lazydap track — pure + immutable is cheap and worth it.
See also
- The Elm Architecture — the pattern this enables
- Side Effects via Cmd — how TEA handles necessary impurity
- Reducers — the generalisation in mainstream code
- Elm (the language) — the language built around these ideas