JavaScript Pure Functions and Side Effects: The Gateway to Functional JS
If you want to write JavaScript that is easy to test, easy to reason about, and easy to refactor without fear — start by separating pure functions from side effects. Every functional pattern, every modern state management library, every React hook rule, every Redux best practice ultimately traces back to this distinction.
This is Part 16 of the JavaScript Fundamentals series. Our existing posts on immutability and SOLID principles in JavaScript complement this one.
The definition #
A pure function has two properties:
- Deterministic — given the same inputs, always returns the same output.
- No side effects — does not modify anything outside itself.
Examples:
// Pure: same inputs → same output, no external mutation
const add = (a, b) => a + b;
const capitalize = (s) => s[0].toUpperCase() + s.slice(1);
const pickEvens = (arr) => arr.filter(n => n % 2 === 0);
Impure:
// Impure: depends on or modifies external state
let counter = 0;
const tick = () => ++counter; // mutates outer variable
const now = () => Date.now(); // not deterministic
const log = (m) => console.log(m); // I/O side effect
const saveUser = (u) => db.save(u); // database side effect
What counts as a side effect #
Anything observable outside the function's return value:
- Modifying a parameter (object/array passed by reference).
- Modifying a variable in an outer scope.
- Modifying global state (
window,globalThis, module-levellet). - Reading/writing the DOM.
- Reading/writing the network or filesystem.
- Reading or writing
localStorage/ cookies. - Calling
console.log(yes, technically a side effect). - Throwing an exception (debated — most functional purists count it).
- Reading the current time /
Math.random()/crypto.randomUUID().
If you delete every call to a function and nothing else in your program would behave differently except that it would now produce wrong values, the function is pure.
Why purity matters #
Easy to test #
// Pure — trivial to test
const slugify = (s) => s.toLowerCase().replace(/\s+/g, '-');
slugify('Hello World') === 'hello-world'; // ✓
No mocks, no spies, no setup. The test IS the assertion. Compare:
// Impure — hard to test
function saveUserAndNotify(user) {
db.users.save(user);
emailer.send(user.email, 'Welcome');
analytics.track('user.signup', { id: user.id });
}
To test this, you mock three things, verify three calls happened, and your test breaks every time someone refactors the body. Pure functions are the opposite — refactor freely, the test only asserts on output.
Easy to memoize, parallelize, and reorder #
Pure functions can be cached forever. They can run in any order. They can run in parallel. Build tools can do tree-shaking and dead-code elimination on them. Frameworks like React assume their render functions are pure precisely because that lets them skip work, batch updates, and reuse results.
Easy to reason about #
Reading an impure function means asking "what else does this touch?" — a global, a database, an event emitter. Reading a pure function means looking at parameters and the return — the entire universe.
Side effects ARE necessary #
A program with zero side effects is a calculator that doesn't print. Real applications need to talk to the database, render to the DOM, send the email. The goal is not zero side effects — it is isolating side effects so the rest of your code is pure.
The textbook pattern:
// IMPURE shell — does I/O
async function handleSignup(req, res) {
const user = req.body;
const validated = validateUser(user); // PURE
if (!validated.ok) return res.status(400).json(validated.error); // I/O
const enriched = enrichUser(validated.value); // PURE
await db.users.insert(enriched); // I/O
await mailer.send(welcomeEmail(enriched)); // I/O
res.json({ id: enriched.id }); // I/O
}
// PURE core
const validateUser = (u) => u.email ? { ok: true, value: u } : { ok: false, error: 'email' };
const enrichUser = (u) => ({ ...u, id: cryptoUUID(u), createdAt: TIMESTAMP });
const welcomeEmail = (u) => ({ to: u.email, subject: 'Welcome', body: `Hi ${u.name}` });
The shell does I/O. The core is pure. Tests for validateUser, enrichUser, and welcomeEmail are pure-function tests — one line each. Tests for handleSignup mock the db/mailer once.
This pattern has 50 names: "functional core, imperative shell" (Gary Bernhardt), "hexagonal architecture" (Alistair Cockburn), "ports and adapters", "clean architecture". Same idea everywhere.
Immutability — purity's twin #
For a function to be pure, it cannot mutate its arguments. So pure functions force you to use immutable patterns:
// Impure — mutates input
function addItem(cart, item) {
cart.items.push(item); // mutation
cart.total += item.price; // mutation
return cart;
}
// Pure — returns new object
function addItem(cart, item) {
return {
...cart,
items: [...cart.items, item],
total: cart.total + item.price,
};
}
The pure version is what React's useState setter expects, what Redux reducers must return, what Vue's reactivity tracks correctly. Mutability silently breaks every modern framework.
We go deeper into the mutation patterns in our post on mutability and immutability.
The reference-vs-value trap #
Object parameters arrive by reference. Mutating them mutates the caller's value — which violates purity even when the function looks pure:
function normalize(user) {
user.email = user.email.toLowerCase(); // ✗ mutates caller's user
return user;
}
const alice = { email: 'Alice@example.com' };
normalize(alice);
alice.email; // 'alice@example.com' — alice was changed under the caller's feet
The pure version returns a new object:
function normalize(user) {
return { ...user, email: user.email.toLowerCase() };
}
Now alice is untouched. The caller chooses whether to use the result.
When to break purity #
Not every function needs to be pure. Healthy guidelines:
- Pure by default — every utility, helper, transformation, validation, calculation, formatter.
- Impure at boundaries — code that reads input (HTTP, DOM, files) or writes output (network, DOM, logs). Keep these small and named clearly.
- Impure when performance demands it — sometimes mutation is the only viable path (in-place sorts on giant arrays, doubly-linked lists). Document the mutation; do it locally, not on shared state.
- Always pure for shared utilities — helpers used by multiple modules should never have a side effect that surprises any of them.
Patterns that follow from purity #
Once you commit to pure cores, several patterns emerge naturally:
Currying #
Partial application is trivially safe with pure functions. See our currying deep-dive.
Memoization #
Cache pure-function results indefinitely. Closures + WeakMaps make this cheap. We showed the pattern in Part 4 on closures.
Higher-order functions #
Functions that take functions and return functions. The whole topic of Part 17.
Reducers #
(state, action) => newState — a pure reducer is the heart of Redux, useReducer, and event-sourcing systems.
Pipelines #
pipe(parse, validate, normalize, persist) — pure functions compose into pipelines you can read top-to-bottom.
Common gotchas #
Gotcha 1: Math.random() makes any function that calls it impure.
For testability, inject randomness:
function pickWinner(entries, random = Math.random) {
return entries[Math.floor(random() * entries.length)];
}
Now tests can pass () => 0.5 and get deterministic results.
Gotcha 2: Date.now() and new Date() are impure.
Same fix — inject a clock: function expiresAt(ttl, now = Date.now) { return now() + ttl; }.
Gotcha 3: Array.prototype.sort mutates.
const sorted = arr.sort(); // ✗ mutates arr
const sorted = [...arr].sort(); // ✓ copy first
const sorted = arr.toSorted(); // ✓ ES2023 non-mutating version
Most array methods are pure (map, filter, slice). The mutating ones are sort, reverse, splice, push, pop, shift, unshift, fill, copyWithin. ES2023 added non-mutating toSorted, toReversed, toSpliced, with. Use those when available.
Gotcha 4: console.log inside a function is a side effect.
Functional purists will tell you to remove it. Pragmatic answer: log inside the impure shell, not in pure cores. If you must log during a computation, accept the impurity is real and limited.
Recap #
- A pure function is deterministic and has no side effects.
- Side effects = anything observable outside the return: mutation, I/O, time, randomness, exceptions.
- Pure functions are easy to test, memoize, parallelize, compose, reason about.
- Real apps need side effects — isolate them at boundaries and keep the core pure.
- Mutation of object/array parameters silently violates purity. Spread,
toSorted,toReversedto copy. - Inject impure dependencies (clock, randomness, db) instead of calling them directly — turns impure functions into testable ones.
Next up: Higher-Order Functions and Function Composition — the patterns (map, filter, reduce, pipe, compose) that turn pure functions into pipelines.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


