Pure functions and side effects in JavaScript
Pure function - a function that returns the same output for the same input every time and changes nothing outside itself.
Theory
TL;DR
- Analogy: a pure function is like a vending machine. Same coins in, same soda out. It does not touch the store's inventory.
- Main difference: pure means output depends only on inputs; impure means it depends on time, globals, or external state.
- Decision rule: use pure for math, logic, and data transforms. Impure only where you must (API calls, DOM, timers).
constdoes not mean pure. Objects declared withconstare still mutable.- Memoization (
React.memo,useMemo) only works correctly with pure functions.
Quick example
// Pure: same input always returns same output
const add = (a, b) => a + b;
add(2, 3); // 5, always
// Impure: result changes when discount changes
let discount = 0.1;
const getPrice = (price) => price * (1 - discount);
getPrice(100); // 90 today, 80 if discount becomes 0.2
// Pure fix: pass discount as an argument
const getPricePure = (price, discount) => price * (1 - discount);
getPricePure(100, 0.1); // 90, always for these inputsThe impure getPrice reads discount from the outer scope. Reset that variable in a test, or run the function in a different order, and you get a different result. That is the whole problem.
Key difference
Pure functions work like math equations: the output is fully determined by the inputs, nothing more. Side effects break that guarantee by reading from the outside world (Date.now(), a global variable) or writing to it (array.push(), localStorage.setItem()). Once a function does either, you cannot trust that the same call produces the same result.
When to use
- Math or logic calculations: pure. No exceptions.
- Sorting, filtering, transforming arrays: pure (return a new array, never mutate the input).
- React component rendering, Redux reducers: pure. React's reconciliation depends on this.
- API calls, timers, DOM updates, logging: impure by nature. Isolate them at the edge of your code.
- Need memoization: pure only. Caching breaks silently on impure functions.
How V8 handles this
V8 (the engine behind Chrome and Node.js) applies inline caching and escape analysis to functions that read no external state and produce no mutations. It can place return values on the stack instead of the heap and skip redundant property lookups. As soon as a function touches external state or performs I/O, V8 treats it as unpredictable and backs off those optimizations. This is why Redux recommends pure reducers, and why Immer uses Proxy traps to simulate in-place mutations while actually producing a new state object.
Common mistakes
Mistake 1: thinking Array.map is always pure
map returns a new array, but the callback runs arbitrary code. If it touches external state, the whole call is impure.
let calls = 0;
[1, 2].map(n => { calls++; return n * 2; });
// New array returned, but calls === 2 now. Side effect.Fix: keep callbacks stateless. Every dependency goes in as an argument.
Mistake 2: treating const as immutable
const prevents reassignment. It does not prevent mutation of what the variable points to.
const arr = [1, 2];
arr.push(3); // arr is now [1, 2, 3]. Mutated.Fix: use spread to create a new array, or Object.freeze for shallow immutability.
Mistake 3: closures with captured mutable state
A closure captures variables from its surrounding scope. If those variables are mutable, the function is impure even if it looks clean.
let x = 1;
const increment = () => x++; // Reads and writes external x. Impure.Fix: pass the value as a parameter instead of capturing it.
Mistake 4: assuming async functions can be pure
fetch is always impure: it depends on the network, time, and server state. Wrapping it in a named function does not change that.
const getData = () => fetch('/api'); // Impure. Network is external state.Fix: mock I/O in tests. Accept that impure code lives at the boundary.
Mistake 5: Object.freeze freezes deeply
Object.freeze freezes only the top level. Nested objects remain mutable.
const state = Object.freeze({ user: { name: 'Alex' } });
state.user.name = 'Bob'; // Works. No error. Nested object is not frozen.Fix: spread every level you update: { ...state, user: { ...state.user, name: 'Bob' } }.
Real-world usage
- React: pure components re-render correctly with
React.memo. Impure ones may produce stale renders. - Redux: reducers must be pure. Immer lets you write mutative-looking syntax that still produces new state.
- Node/Express: pure validators (Joi schemas) run before any DB write. Validation is pure; the write is not.
- Ramda/Lodash-fp: designed around pure functions.
R.mapandR.filternever mutate inputs.
I once spent two hours debugging a totals calculation in a checkout flow. The bug was a function reading a global discount flag that an unrelated test had reset. Making the function pure and passing the discount as an argument fixed the test and the bug in a single change.
Follow-up questions
Q: Can a function that calls console.log inside be considered pure?
A: No. console.log writes to an external I/O stream. That is a side effect, regardless of what the function returns.
Q: Is Array.sort() pure?
A: No. It mutates the original array in place. Use [...arr].sort() to get a sorted copy and leave the original untouched.
Q: Why does React.memo fail with impure components?
A: React.memo skips re-render when props have not changed. An impure component might produce different output with the same props (reading Date.now(), a global counter), so the cached result becomes wrong.
Q: Redux Toolkit uses Immer inside createSlice. Does that make reducers impure?
A: No. Immer intercepts mutations via Proxy and returns a new state object. The reducer receives state and returns a new one. V8 does not deoptimize because no real mutation escapes the function scope.
Q: What is referential transparency?
A: A pure function is referentially transparent: you can replace any call with its return value and the program behaves identically. That property is what makes memoization and safe parallel execution possible.
Examples
Basic: making a price function pure
// Impure: reads external discount variable
let discount = 0.1;
const getPriceImpure = (price) => price * (1 - discount);
console.log(getPriceImpure(100)); // 90
discount = 0.2;
console.log(getPriceImpure(100)); // 80 - same call, different result
// Pure: all inputs are explicit
const getPricePure = (price, discount) => price * (1 - discount);
console.log(getPricePure(100, 0.1)); // 90, always
console.log(getPricePure(100, 0.2)); // 80, always - predictableEvery external dependency becomes a parameter. Now you can test this with any combination of values without any setup or teardown.
Intermediate: filtering a todo list in React
// Impure: mutates the original array (breaks React reconciliation)
const filterCompletedImpure = (todos) => {
todos.forEach((todo, i) => {
if (todo.complete) todos.splice(i, 1);
});
return todos;
};
// Pure: returns a new array, original untouched
const filterCompleted = (todos) =>
todos.filter(todo => !todo.complete);
const todos = [
{ id: 1, complete: true },
{ id: 2, complete: false },
];
console.log(filterCompleted(todos));
// [{ id: 2, complete: false }]
console.log(todos);
// [{ id: 1, complete: true }, { id: 2, complete: false }] - unchangedReact compares previous and next props to decide whether to re-render. Mutating an array in place keeps the same reference, so React may skip the update and leave the UI stale.
Advanced: shallow freeze vs deep clone in Redux-style state
const state = { count: 5, user: { name: 'Alex' } };
// Object.freeze is shallow
const frozen = Object.freeze({ ...state });
frozen.count = 10; // Silent fail in sloppy mode, TypeError in strict
frozen.user.name = 'Bob'; // Works. Nested object is NOT frozen.
// Pure update with spread
const nextState = { ...state, count: state.count + 1 };
console.log(nextState); // { count: 6, user: { name: 'Alex' } }
console.log(state); // { count: 5, user: { name: 'Alex' } } - untouched
// Deep update: spread every level you change
const nextStateDeep = {
...state,
user: { ...state.user, name: 'Bob' },
};
console.log(nextStateDeep.user); // { name: 'Bob' }
console.log(state.user); // { name: 'Alex' } - still safeIn production Redux, Immer handles nested updates automatically. Knowing why it exists shows you understand the constraint, not just the API.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.