Skip to content
JavaScript js functional 4 min read

Pure Functions & Side Effects

A pure function is the smallest, most reliable building block in functional programming: given the same input it always returns the same output, and it changes nothing outside itself. That single guarantee makes code easier to test, reason about, cache, and run in parallel. Most real applications can’t be entirely pure — they have to touch the network, the DOM, or the clock — but the goal is to push that impurity to the edges and keep the core logic clean.

What makes a function pure

A function is pure when it satisfies two rules at once:

  1. Deterministic — the return value depends only on its arguments. Same inputs, same output, every time.
  2. No side effects — it doesn’t mutate external state, write to a file, call an API, log to the console, or modify its own arguments.
// Pure: output depends only on inputs, nothing else changes
function add(a, b) {
  return a + b;
}

// Pure: returns a NEW array instead of mutating the original
function double(numbers) {
  return numbers.map((n) => n * 2);
}

console.log(add(2, 3));
const original = [1, 2, 3];
console.log(double(original));
console.log(original); // untouched

Output:

5
[ 2, 4, 6 ]
[ 1, 2, 3 ]

Impure functions and what trips them up

Impurity sneaks in through shared state, time, randomness, and I/O. The functions below look harmless but break one of the two rules.

let total = 0;

// Impure: mutates external state
function addToTotal(amount) {
  total += amount;
  return total;
}

// Impure: non-deterministic (depends on the clock)
function greet(name) {
  return `${name}, it is ${Date.now()}`;
}

// Impure: mutates its argument
function addItem(cart, item) {
  cart.push(item); // caller's array is changed
  return cart;
}

A reliable tell: if calling the function twice with identical arguments can produce different results, or if anything outside the function is different afterward, it’s impure.

Source of impurityExamplePure alternative
Shared mutable statetotal += amountPass state in, return new state
Argument mutationarr.push(x)[...arr, x] (spread / copy)
TimeDate.now()Accept a now parameter
RandomnessMath.random()Accept a seed or value
I/Ofetch, console.logMove to the application edge

Why purity pays off

Testability. Pure functions need no mocks, stubs, or setup — you pass inputs and assert on outputs. There’s no hidden environment to arrange.

function discountPrice(price, percent) {
  return price - price * (percent / 100);
}

console.assert(discountPrice(100, 20) === 80, "20% off 100 should be 80");

Memoization. Because a pure function’s output is fixed for a given input, you can safely cache results and skip the recomputation.

function memoize(fn) {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

const slowSquare = (n) => n * n;
const fastSquare = memoize(slowSquare);

console.log(fastSquare(9)); // computed
console.log(fastSquare(9)); // served from cache

Output:

81
81

Concurrency. Pure functions share no mutable state, so there are no race conditions. The same code runs safely across Web Workers, worker threads, or any parallel pipeline.

Caching an impure function is a classic bug. If the result depends on time, randomness, or external state, a memo cache will happily return stale, wrong answers. Only memoize functions you’ve verified are pure.

Isolating side effects

You can’t eliminate side effects — you isolate them. Keep the decision-making logic pure and let a thin imperative shell perform the effects. This is often called functional core, imperative shell.

// Pure core: all the rules live here, fully testable
function applyDeposit(account, amount) {
  if (amount <= 0) throw new Error("Amount must be positive");
  return { ...account, balance: account.balance + amount };
}

// Imperative shell: the only place that touches the outside world
async function deposit(accountId, amount, db) {
  const account = await db.load(accountId); // effect: read
  const updated = applyDeposit(account, amount); // pure
  await db.save(updated); // effect: write
  return updated;
}

All the interesting behaviour — validation, the new balance — sits in applyDeposit, which you can test with plain objects. The effects (load, save) are pushed to the boundary where they’re easy to swap or mock.

Best practices

  • Return new values instead of mutating arguments — reach for spread, map, filter, and Object.assign({}, ...).
  • Pass dependencies like the current time, IDs, or random values in as arguments so functions stay deterministic.
  • Keep console.log, fetch, DOM writes, and storage at the edges of your app, not buried inside business logic.
  • Build features by composing small pure functions rather than one large stateful one.
  • Only memoize functions you’ve confirmed are pure; caching impure results yields stale data.
  • Use Object.freeze during development to catch accidental mutations early.
Last updated June 1, 2026
Was this helpful?