Skip to main content

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).
  • const does not mean pure. Objects declared with const are still mutable.
  • Memoization (React.memo, useMemo) only works correctly with pure functions.

Quick example

javascript
// 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 inputs

The 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.

javascript
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.

javascript
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.

javascript
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.

javascript
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.

javascript
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.map and R.filter never 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

javascript
// 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 - predictable

Every 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

javascript
// 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 }] - unchanged

React 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

javascript
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 safe

In production Redux, Immer handles nested updates automatically. Knowing why it exists shows you understand the constraint, not just the API.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?