AniUI Academy

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.

15 min read

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.

Try it yourself
Loading playground...

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.
  • pipe left to right, compose right 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

up to 50
  1. 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. 2.In which order does pipe(parse, validate, save)(input) run the three functions?

  3. 3.A recursive function overflows the stack at about 11,000 frames. What is actually true about tail calls today?

  4. 4.Why can Immer let you write draft.items.push(item) and still hand back an immutable next state?

4 left to answer