Lesson 25 of 28
Functional Patterns
Pure functions, composition, currying, memoisation and structural sharing — which of them belong in your application code, and which belong in the library layer.
A dashboard that had run fine for months started dying in the afternoon. Not slowly — the tab would hit two gigabytes and die. The cause was a memoised selector added to fix a rendering hiccup, keyed on a filter object, in a page nobody ever reloaded. The pattern was correct. The eviction policy was missing, because there was no eviction policy.
That is the shape of this lesson. Every technique here is a trade, and the useful skill is knowing what you are paying.
Purity, and what it actually buys
A pure function returns the same output for the same input and touches nothing
outside itself. No Date.now(), no Math.random(), no network, no mutation of
its arguments.
The payoff is concrete, not aesthetic:
- You can test it with a table of inputs and expected outputs, no mocks and no setup.
- You can cache it, because equal inputs guarantee equal outputs.
- You can move it to a worker, because it has no shared state to race on.
- You can reorder or skip it, which is exactly what React's rendering model assumes.
Impurity is not a sin, it is the point of the program. The discipline is to push
it to the edges: read the clock once at the top of a handler and pass the value
down, rather than have six functions each reach for Date.now() and disagree
by a millisecond.
// impure, untestable without freezing time
function isExpired(token) {
return token.expiresAt < Date.now();
}
// pure, and the caller decides what "now" means
function isExpired(token, now) {
return token.expiresAt < now;
}The second version is what lets you test the boundary condition instead of hoping.
Immutability, and its bill
Not mutating shared data removes an entire class of bug — the one where a
function three layers down sorts your array in place and the caller's order
changes underneath it. sort, reverse, splice, push and fill all
mutate. toSorted, toReversed, toSpliced and with are the copying
versions, now available across current browsers and Node 20 and up.
The bill comes due in hot paths. {...state, items: [...state.items, item]}
allocates a new object and a new array every time. On a hundred items that is
free. Appending to a ten-thousand-item array inside a keystroke handler is not,
and neither is Object.freeze on a large tree — it is shallow, so people call
it recursively, and it slows every subsequent property access on frozen objects
in some engines.
Copying is the default. Mutating a local object that has not escaped the
function yet is fine, and building an array with push inside a reduce is
fine as long as the accumulator was created in that call. The rule is about
shared data, not about the keyword.
Composition
Functions are values, so you can build one out of several. Written out, pipe
is four lines:
const pipe = (...fns) => (input) => fns.reduce((acc, fn) => fn(acc), input);
const compose = (...fns) => (input) => fns.reduceRight((acc, fn) => fn(acc), input);
const slugify = pipe(
(s) => s.trim(),
(s) => s.toLowerCase(),
(s) => s.replace(/[^a-z0-9]+/g, "-"),
(s) => s.replace(/^-|-$/g, "")
);
slugify(" The Event Loop! "); // "the-event-loop"pipe reads left to right; compose right to left. Pick one name and enforce
it, because a codebase containing both will eventually get them the wrong way
round in a place where the types happen to line up.
Composition pays when the steps are named and reusable. Composing four inline
arrows, as above, is a stylistic choice with no advantage over four statements
and four const bindings — and the statements produce better stack traces.
Currying and partial application
Currying turns f(a, b, c) into f(a)(b)(c). Partial application fixes some
arguments and leaves the rest.
const log = (level) => (scope) => (message) =>
console.log(`[${level}] ${scope}: ${message}`);
const warnAuth = log("warn")("auth");
warnAuth("token refresh failed"); // [warn] auth: token refresh failed
// the same thing with bind, no nesting required
function rawLog(level, scope, message) { /* ... */ }
const warnAuth2 = rawLog.bind(null, "warn", "auth");This reads well when the fixed arguments are configuration and the last one is
the data — a logger, a formatter, an event handler factory. It reads badly the
moment argument order is chosen to serve currying rather than the caller, or
when a stack trace turns into six anonymous frames. bind and a closure cover
most real cases without the ceremony.
Higher-order functions past the array methods
The array methods are the famous ones. The useful ones wrap behaviour:
const once = (fn) => {
let called = false;
let result;
return (...args) => {
if (!called) {
called = true;
result = fn(...args);
}
return result;
};
};
const withRetry = (fn, attempts = 3) => async (...args) => {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn(...args);
} catch (error) {
lastError = error;
await new Promise((r) => setTimeout(r, 2 ** i * 100));
}
}
throw lastError;
};Retry, timeout, rate limit, instrument, cache. Each is a function that takes a function and returns one with the same signature, which is why they stack.
Memoisation, and the leak
function memoise(fn, keyOf = (...args) => args.join("|")) {
const cache = new Map();
return (...args) => {
const key = keyOf(...args);
if (cache.has(key)) return cache.get(key);
const value = fn(...args);
cache.set(key, value);
return value;
};
}Two things decide whether this helps or hurts.
The key. JSON.stringify(args) is the lazy default and it is wrong twice:
key order changes the string, so {a:1,b:2} and {b:2,a:1} become different
entries, and it is often slower than the function you are caching. Build the
key explicitly from the fields that matter. If the argument is a single object
whose identity is stable, a WeakMap is better — entries vanish when the
object does.
The eviction. A Map holds keys and values strongly and forever. Bound it,
by size with an LRU, or by time with a TTL. A five-line cap is enough:
if (cache.size > 500) cache.delete(cache.keys().next().value);Map preserves insertion order, so that evicts the oldest entry. Skip this and
you have written a memory leak with good intentions.
That cap stands in for the LRU you would use in production; the behaviour to watch is that a bounded cache trades hits for a memory ceiling, deliberately.
Point-free, and the tax
Point-free style drops the parameter: users.map(getName) instead of
users.map((u) => getName(u)). At one level it is cleaner. Past that it turns
into puzzles, and the tax is paid by whoever debugs it at 2am with a stack full
of anonymous frames. Name your arguments when naming them explains the code.
One real hazard to know: ["1", "2", "3"].map(parseInt) gives [1, NaN, NaN],
because map passes the index as the radix.
Recursion and the missing optimisation
Proper tail calls are in the specification. Safari shipped them; V8 and SpiderMonkey refused, citing lost stack traces and implementation cost. So in Chrome, Edge, Firefox and Node, tail position buys you nothing and you overflow at roughly ten thousand frames.
For anything whose depth is data-dependent — walking a DOM tree, a comment thread, a file system — convert to iteration with an explicit stack:
function collect(root) {
const out = [];
const stack = [root];
while (stack.length) {
const node = stack.pop();
out.push(node.value);
stack.push(...node.children);
}
return out;
}Uglier, unbounded, and it never dies on someone else's deeply nested data.
Structural sharing
Copying a whole state tree on every update sounds wasteful, and it would be if you copied the whole tree. You do not. You copy the nodes along the path to the change and reuse every other branch by reference.
That is what makes prevState.settings === nextState.settings a meaningful
answer to "did settings change", which is what every memoised selector and
React.memo boundary relies on. It is also why the deep clone people reach for
first is a bug: it changes every reference, so nothing can be skipped.
Immer exists because writing those path copies by hand is error-prone once the tree is four levels deep. It hands you a Proxy, records the writes you make against it, and produces the path-copied result. You write mutable-looking code and get correct sharing. The cost is a library and a Proxy on your hot path, which is a fair trade for reducers and a poor one for a per-frame animation loop.
Where to draw the line
Use everywhere: pure functions, immutable updates to shared state, small
higher-order wrappers such as once and withRetry.
Use with care: pipe when the steps are named, memoisation only after
measuring and always with a bound, currying where the fixed arguments are
genuinely configuration.
Leave in the library layer: point-free chains, transducers, lenses, combinator-heavy composition. They are not wrong, they are just a dialect, and application code is read by people who did not choose it.
What to remember
- Purity buys testability, cacheability and the freedom to skip or reorder work.
- Immutability is the default, but copying large structures per keystroke is a real cost.
pipeleft to right,composeright to left. Pick one.- Every memoisation needs a deliberate key and a deliberate eviction rule.
- Tail-call optimisation is not available outside Safari. Rewrite deep recursion as a loop.
- Structural sharing, not deep cloning, is what makes identity checks useful.
Check yourself
4 questions · pass 3/4 to unlock Performance and Rendering
1.You memoise a lookup with a plain
Map, keyed by a filter object turned into a string, in a dashboard that stays open all day. What breaks first?2.In which order does
pipe(parse, validate, save)(input)run the three functions?3.A recursive function overflows the stack at about 11,000 frames. What is actually true about tail calls today?
4.Why can Immer let you write
draft.items.push(item)and still hand back an immutable next state?
4 left to answer