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:

  1. It always returns the same output for the same inputs.
  2. 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:

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

When pure + immutable isn't worth it

For UI state, agent state, debugger state — all the things mxr/lazydap track — pure + immutable is cheap and worth it.

See also