How do pure functions help you write testable frontend code?
Pure functions are deterministic and side-effect-free: same inputs → same output, no I/O, no mutation of external state. They are trivially testable because they need no mocks, fixtures, or setup. Architecturally, the goal is to push impure code (fetch, DOM, time, randomness) to the edges and keep the core logic pure. This is the 'functional core, imperative shell' pattern that makes UIs predictable and refactorable.
What 'pure' means
A pure function:
- Returns the same output for the same inputs (deterministic).
- Has no side effects (no I/O, no DOM, no mutation of arguments or external state).
// Pure
const total = (items) => items.reduce((s, x) => s + x.price, 0);
// Impure — reads time, mutates DOM
const showTotal = (items) => {
document.getElementById('total').textContent = total(items) + ' at ' + Date.now();
};Why this matters for testability
Pure functions are the cheapest possible thing to test:
expect(total([{price: 1}, {price: 2}])).toBe(3);No mocks. No fixtures. No setup. No timers. No DOM. Just inputs → outputs.
Impure functions need: mocking fetch, faking Date.now, jsdom, beforeEach, cleanup. Test cost rises by 10-50x.
Architectural impact: functional core, imperative shell
The pattern, popularized by Gary Bernhardt:
┌─────────────────────────────┐
│ Imperative Shell │ <- fetch, DOM, localStorage, time
│ ┌───────────────────────┐ │
│ │ Functional Core │ <- pure transformations
│ │ (pure functions) │ │
│ └───────────────────────┘ │
└─────────────────────────────┘The shell does I/O and hands raw data to the core. The core computes a result. The shell takes the result and writes it back to the DOM / network.
Concrete React example
Impure (hard to test):
function Cart() {
const [items, setItems] = useState([]);
useEffect(() => {
fetch('/cart').then(r => r.json()).then(data => {
const filtered = data.filter(i => i.qty > 0);
const total = filtered.reduce((s, x) => s + x.price * x.qty, 0);
setItems({ filtered, total });
});
}, []);
}To test this, you mock fetch, render in jsdom, wait for effects, query the DOM.
Refactored — pure core extracted:
// pure.js — trivially testable
export const cleanCart = (items) => items.filter(i => i.qty > 0);
export const cartTotal = (items) => items.reduce((s, x) => s + x.price * x.qty, 0);
// Cart.jsx
function Cart() {
const [raw, setRaw] = useState([]);
useEffect(() => { fetch('/cart').then(r => r.json()).then(setRaw); }, []);
const items = cleanCart(raw);
const total = cartTotal(items);
return <div>{total}</div>;
}Now cleanCart and cartTotal have unit tests. The component has one integration test.
What to push to the edges
- fetch / HTTP
- DOM read/write
- localStorage / sessionStorage / IndexedDB
- Date.now / new Date()
- Math.random
- console.log
- setTimeout / setInterval
- Anything global (window, navigator)
Refactoring tactics
- Inject time and randomness as parameters:
fn(input, { now, rng }). - Return data, not actions: have the function return what to do; let the shell execute it.
- Avoid mutating arguments: copy first.
- Avoid Date.now() inside the function: pass it in.
- Avoid reading globals: pass config.
What pure functions also give you
- Memoization is safe (same input → same output).
- Parallelizable — no shared state.
- Time-travel debugging — replay inputs to reproduce bugs.
- Easier code review — no spooky action at a distance.
Trade-offs
- Some logic genuinely is impure (animation, streaming). Don't contort it.
- Excessive purity (passing fetch as a parameter everywhere) becomes noise.
- The 'shell' still needs tests — usually integration / e2e.
Mental model
Pure functions are the unit of testability. The more of your logic lives in pure functions, the cheaper your test suite. Architect the app so the impure stuff (I/O, time, DOM) is a thin shell around a fat pure core. The result: most bugs reproducible with two inputs and an expect, and refactors that don't require rewriting the test suite.
Follow-up questions
- •How do you handle time and randomness in pure functions?
- •What's the functional-core / imperative-shell pattern?
- •When does pursuing purity become counterproductive?
- •How does this interact with React hooks and effects?
Common mistakes
- •Calling Date.now() or Math.random() inside otherwise-pure logic.
- •Mutating function arguments — silent action-at-a-distance bugs.
- •Mixing fetch / DOM with computation in the same function.
- •Treating purity as binary instead of a design pressure.
- •Forgetting that the shell still needs integration tests.
Performance considerations
- •Pure functions enable memoization, structural sharing, and parallelization. They also make profiling easier — hot paths are isolatable. Cost: occasional extra copies to avoid mutation, usually negligible.
Edge cases
- •Generators and iterators — pure if inputs are.
- •Memoization itself is technically impure (hidden cache) but referentially transparent.
- •Logging — pragmatic exception in many codebases.
- •React: components are pure functions of props; effects are the shell.
Real-world examples
- •Redux reducers are required to be pure — that's why they're testable.
- •React render functions are pure in concept (effects pushed to useEffect).
- •Immer / Immutable.js make pure-style updates ergonomic.
- •RxJS operators are mostly pure.