Pure Functions and Side Effects
Mental model: A pure function is a formula: the same input always gives the same output, and nothing outside it can tell that it ran.
Level: intermediate · about 10 minutes
let taxRate = 0.2;
function impureTotal(price) {
taxRate += 0.05; // changes something outside itself
return price * (1 + taxRate);
}
console.log(impureTotal(100)); // → 125
console.log(impureTotal(100)); // → 130 same input, different answer
const pureTotal = (price, rate) => price * (1 + rate);
console.log(pureTotal(100, 0.2), pureTotal(100, 0.2)); // → 120 120Same call, twice, with two different outcomes for the caller.
A function is pure when it satisfies two conditions: the return value depends only on its arguments, and calling it changes nothing observable outside itself. Everything else is a side effect: writing to a variable in an outer scope, mutating an argument, logging, touching the DOM, reading the clock, generating a random number, or making a request.
| Function | Pure? | Why |
|---|---|---|
(a, b) => a + b | yes | output depends only on input |
(list) => [...list].sort() | yes | sorts a copy, leaves the argument alone |
(list) => list.sort() | no | mutates the array the caller passed in |
() => Date.now() | no | reads the outside world, different answer each call |
(x) => { console.log(x); return x; } | no | logging is an observable effect |
(id) => fetch(url) | no | performs a request |
Referential transparency
A pure call can be replaced by its result without changing the program. That property is called referential transparency, and it is what makes caching, memoisation, reordering and parallel work safe. Try it with an impure function and you change behaviour.
const slug = (title) => title.trim().toLowerCase().replaceAll(' ', '-');
// Because slug is pure, these two lines are interchangeable:
console.log(slug(' Hello World ')); // → 'hello-world'
console.log('hello-world'); // → 'hello-world'
// And caching it can never be wrong:
const cache = new Map();
const cachedSlug = (title) => {
if (!cache.has(title)) cache.set(title, slug(title));
return cache.get(title);
};
console.log(cachedSlug('A B'), cachedSlug('A B'), cache.size); // → 'a-b' 'a-b' 1
Immutable updates
Most accidental impurity comes from mutating an argument. The fix is a copy plus a change, which the spread syntax and the non-mutating array methods make short.
Mutating: the caller is surprised
function addItem(list, item) {
list.push(item); // edits their array
return list;
}
const original = ['a'];
const next = addItem(original, 'b');
console.log(original); // ['a', 'b'] changed
console.log(next === original); // true
Copying: the caller is safe
function addItem(list, item) {
return [...list, item];
}
const original = ['a'];
const next = addItem(original, 'b');
console.log(original); // ['a'] untouched
console.log(next === original); // falseThe mutating version also lies about its return value: callers cannot tell whether they got a new array or the same one, so a comparison like prev !== next (used by every rendering library) stops working.
const user = { id: 1, name: 'Ada', tags: ['admin'] };
const items = [1, 2, 3];
console.log({ ...user, name: 'Grace' }); // change one field
const { tags, ...withoutTags } = user; // remove a field
console.log(withoutTags);
console.log([...items, 4]); // append
console.log(items.filter((n) => n !== 2)); // remove
console.log(user, items); // → both originals unchangedThe four everyday immutable updates.
Isolating the effects
// Pure: all the decisions live here, and it is trivial to test.
function applyDiscount(order, percent) {
return { ...order, total: order.total * (1 - percent / 100) };
}
// Impure shell: reads input, calls the pure part, performs the effect.
function checkout(order, percent) {
const discounted = applyDiscount(order, percent);
console.log('charging', discounted.total); // the only effect
return discounted;
}
console.log(checkout({ id: 7, total: 200 }, 25));Calculation in the middle, effects at the edge.
When a function needs something from the outside world, take it as a parameter instead of reaching for it. Pass now rather than calling Date.now(), pass a random function rather than calling Math.random(). The function becomes pure, and your tests become boring, which is the goal.
Reaches out: hard to test
function isExpired(token) {
return token.expiresAt < Date.now();
}
// the test result changes with the clock
Takes it in: trivial to test
function isExpired(token, now = Date.now()) {
return token.expiresAt < now;
}
isExpired({ expiresAt: 50 }, 100); // true, alwaysA default parameter keeps the convenient call site while making the dependency injectable. This one change turns a flaky test into a deterministic one, and it costs a single parameter.
deterministic- same arguments, same result, every time
no side effects- nothing outside can tell it ran
testable- no mocks, no setup, no teardown
cacheable- safe to memoise, because the answer cannot change
const nums = [3, 1, 2];
function smallest(list) {
return list.sort((a, b) => a - b)[0];
}
console.log(smallest(nums), nums);sort sorts in place and returns the same array, so smallest quietly reorders the caller array. The result is 1, but nums is now [1, 2, 3]. Copy first with [...list].sort(…) and the function becomes pure.
Try it yourself
Make it pure
const original = { id: 1, name: 'Ada', tags: ['admin'] };
function addTagImpure(user, tag) {
user.tags.push(tag);
return user;
}
function addTagPure(user, tag) {
return { ...user, tags: [...user.tags, tag] };
}
const a = addTagPure(original, 'editor');
console.log('pure result:', a);
console.log('original after pure call:', original);
const b = addTagImpure({ ...original }, 'editor');
console.log('shallow copy did not protect the array:', original.tags);
Rewrite addTag so it does not touch the original user, including the nested tags array. Then check that original.tags still has one entry.
Exercises
Add an item without mutating
Write addItem(list, item) which returns a new array with item appended. The array you were given must be unchanged, and the result must be a different array object.
A pure discount
Write applyDiscount(product, percent) which returns a new product with price reduced by percent, rounded to two decimal places. Every other field is carried over unchanged, and the input object must not be modified.
Check yourself
- What does this log?
- true [3, 2, 1] —
reversemutates in place and returns the same array, sooutanditemsare the same object and both read[3, 2, 1].[...list].reverse()ortoReversed()keeps the caller array intact. - Which of these is pure?
(list) => [...list].sort()— Sorting a copy leaves the argument alone and always gives the same result for the same input. The others mutate an argument, read an outside source of randomness, or perform an observable effect.- Why does making a function take
nowas a parameter help? - It removes a hidden dependency, so the function is deterministic and easy to test — Reading the clock inside the function makes the result depend on when the test runs. Passing it in, with a default for convenience, turns a flaky assertion into a fixed one and documents the dependency in the signature.
Common mistakes
- Calling
sort,reverseorspliceon an argument and returning it, which silently edits the caller data. - Assuming a spread copy is deep. Nested objects and arrays are still shared.
- Reading
Date.now()orMath.random()inside logic you then try to test.
Takeaways
- Pure means the result depends only on the arguments and nothing observable changes.
- A pure call can be replaced by its result, which is what makes caching and memoisation safe.
- Copy then change: spread for objects and arrays,
mapandfilterinstead of in-place edits. - Take the outside world as a parameter, and keep the effects at the edges of the program.