Writing Pure Functions in JS: A Complete Tutorial

Learn practical techniques for writing pure functions in JavaScript. Avoid mutation, isolate side effects, and structure your code so the core logic stays predictable and testable.

6 min read

Writing pure functions is a practice, not a toggle. You do not wake up one day and write a purely functional codebase. You build the habit one function at a time, by recognizing the handful of habits that quietly introduce side effects and replacing each one with a small, deliberate alternative. This article shows you those concrete techniques, one at a time, with a before and after example for each.

Technique 1: Do Not Mutate Your Inputs

The most common source of impurity is mutating arguments. If a function changes an array or object that was passed in, it has a side effect. For the conceptual background, see Pure vs Impure Functions in JavaScript Explained.

javascriptjavascript
// Impure: mutates the input array
function addToList(list, item) {
  list.push(item);
  return list;
}
 
// Pure: returns a new array
function addToList(list, item) {
  return [...list, item];
}

The impure version calls push, which changes the original array in place, so any other code holding a reference to that same array sees the new item appear too. The pure version leaves the original list untouched and hands back a brand new array instead. Apply the same pattern to objects, where the mutating assignment becomes a spread that copies the existing properties first:

javascriptjavascript
// Impure: mutates the input object
function setAge(user, age) {
  user.age = age;
  return user;
}
 
// Pure: returns a new object
function setAge(user, age) {
  return { ...user, age };
}

The spread operator (...) creates a shallow copy. For nested objects, you need to spread at each level or use structuredClone, since a shallow copy still shares references to any nested objects with the original:

javascriptjavascript
function updateAddress(user, newCity) {
  return {
    ...user,
    address: {
      ...user.address,
      city: newCity,
    },
  };
}

Notice the nested spread on the address property. Without it, the new object would still point at the exact same address object as the original user, so changing the city there would mutate data the caller still holds a reference to.

Technique 2: Return New Values Instead of Modifying Variables

Avoid reassigning variables that exist outside the function. If you need to build up a result, do it inside the function and return it:

javascriptjavascript
// Impure: modifies outer variable
let total = 0;
function addToTotal(amount) {
  total += amount;
}
 
// Pure: returns the new value
function addToTotal(currentTotal, amount) {
  return currentTotal + amount;
}

The impure version reaches outside itself to update a shared total variable, so calling it twice with the same argument produces different results depending on when you call it. The pure version takes the current state as an argument and returns the new state, so the same call always produces the same output. The caller decides what to do with the result, whether that means storing it back in a variable or passing it along somewhere else.

Technique 3: Pass Dependencies as Arguments

If your function needs a value, pass it in. Do not reach for global variables, module-level state, or environment. This keeps every input the function depends on visible right there in its signature:

javascriptjavascript
// Impure: depends on global tax rate
let taxRate = 0.08;
 
function calculateTax(price) {
  return price * taxRate;
}
 
// Pure: tax rate is an argument
function calculateTax(price, taxRate) {
  return price * taxRate;
}

This also applies to functions. If your function calls another function that is itself impure, like a clock or a random number generator, consider passing that dependency in as an argument too, rather than calling it directly from inside your logic:

javascriptjavascript
// Impure: hard dependency on Date.now()
function isExpired(timestamp, ttl) {
  return Date.now() - timestamp > ttl;
}
 
// Pure: time is passed as an argument
function isExpired(timestamp, ttl, now) {
  return now - timestamp > ttl;
}

The pure version is trivially testable -- you control the clock. The impure version always uses the real time, so a test written against it will produce a different result depending on exactly when it runs.

Technique 4: Use Array Methods Instead of Loops with Side Effects

Loops that push to an external array are impure, since they rely on a variable declared outside the loop and mutate it on every iteration. Array methods like map, filter, and reduce avoid that entirely by returning a new array instead:

javascriptjavascript
// Impure: mutates the external result array
const doubledLoop = [];
for (let i = 0; i < numbers.length; i++) {
  doubledLoop.push(numbers[i] * 2);
}
 
// Pure: map returns a new array
const doubledMap = numbers.map(n => n * 2);

The map version is shorter, declarative, and pure. The loop version is longer, imperative, and risks accidentally mutating the wrong array if the same result array gets reused somewhere else in the code. For more on array methods that return new arrays, see Higher Order Functions in JavaScript: Full Guide.

Technique 5: Isolate Side Effects

Not every function can be pure. The DOM, network, file system, and Math.random are inherently impure. The technique is to isolate these in dedicated functions at the edges of your program, keeping the actual decision-making logic pure and separate from anything that touches the outside world:

javascriptjavascript
function buildGreeting(name, timeOfDay) {
  const greeting = timeOfDay < 12 ? "Good morning" : "Good afternoon";
  return `${greeting}, ${name}!`;
}
 
function formatMessage(greeting, unreadCount) {
  if (unreadCount > 0) {
    return `${greeting} You have ${unreadCount} new messages.`;
  }
  return greeting;
}

Both buildGreeting and formatMessage are pure: they only look at their arguments and return a string. A separate, impure function reads the real data from the DOM and an API, then calls the two pure functions to do the actual thinking:

javascriptjavascript
function updateGreeting() {
  const name = document.getElementById("user-name").textContent;
  const hours = new Date().getHours();
  const unread = getUnreadCount(); // Another impure function (API call)
 
  const greeting = buildGreeting(name, hours);
  const message = formatMessage(greeting, unread);
 
  document.getElementById("greeting").textContent = message;
}

The pure functions, buildGreeting and formatMessage, contain all the logic. The impure function, updateGreeting, is a thin shell that reads inputs, calls the pure functions, and writes the output. This is the functional core, imperative shell pattern.

Technique 6: Avoid Date.now() and Math.random() in Logic

These functions are impure by nature. Pass their results as arguments instead. Here is a Fisher-Yates shuffle with the randomness generated inside the function:

javascriptjavascript
// Impure: randomness generated inside the function
function shuffleArray(array) {
  const result = [...array];
  for (let i = result.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [result[i], result[j]] = [result[j], result[i]];
  }
  return result;
}

The pure version takes the same random values as an argument instead of generating them internally, so the shuffle logic itself becomes fully testable:

javascriptjavascript
// Pure: random values provided as an argument
function shuffleArrayPure(array, randomValues) {
  const result = [...array];
  for (let i = result.length - 1; i > 0; i--) {
    const j = Math.floor(randomValues.shift() * (i + 1));
    [result[i], result[j]] = [result[j], result[i]];
  }
  return result;
}

In production, you pass real random values. In tests, you pass predictable values. The logic is verified independently of the randomness source.

Before and After: A Real Refactor

This last example ties every technique above together in one small, realistic piece of code. The starting point mixes mutation, global state, and DOM writes into a single function, which is exactly the shape most real-world impure code takes:

javascriptjavascript
// Before: impure -- reads from global, writes to DOM, mutates data
let items = [];
 
function addItem(name, price) {
  items.push({ name, price, addedAt: Date.now() });
  document.getElementById("count").textContent = items.length;
  document.getElementById("total").textContent =
    items.reduce((sum, i) => sum + i.price, 0);
}

The refactor pulls the data transformation and the derived calculations out into their own pure functions, each taking the current list as an argument instead of reading it from an outer variable:

javascriptjavascript
function addItemToList(items, name, price, timestamp) {
  return [...items, { name, price, addedAt: timestamp }];
}
 
function getItemCount(items) {
  return items.length;
}
 
function getTotalPrice(items) {
  return items.reduce((sum, item) => sum + item.price, 0);
}

What is left is a thin, impure shell that owns the actual list variable, calls the pure functions to compute the next state and the derived values, then writes those values to the DOM:

javascriptjavascript
let items = [];
 
function handleAddItem(name, price) {
  items = addItemToList(items, name, price, Date.now());
  document.getElementById("count").textContent = getItemCount(items);
  document.getElementById("total").textContent = getTotalPrice(items);
}

The logic, addItemToList, getItemCount, and getTotalPrice, is independently testable. You can verify them without a browser. The impure shell, handleAddItem, is so thin it is almost self-evidently correct.

Building the Habit

None of these six techniques require a new tool or a different framework. Each one is a small, local decision: return a copy instead of mutating, pass in a value instead of reading it from outside, or move a side effect to the edge of the program instead of burying it in the middle of your logic.

Start with the function that is hardest to test or that causes the most confusing bugs, apply one technique, and move on to the next one once it feels natural. Purity is not an all-or-nothing property of a codebase, it is a spectrum, and every function you move a little further along it makes the whole system easier to reason about.

Rune AI

Rune AI

Key Insights

  • Do not mutate inputs. Return new arrays, objects, and values instead.
  • Pass everything a function needs as arguments. Avoid reading from outer scope.
  • Isolate side effects in dedicated functions at the edges of your program.
  • Use map, filter, reduce, and spread instead of loops that mutate external state.
  • Pure functions are a practice. Start with your data transformation logic and expand from there.
RunePowered by Rune AI

Frequently Asked Questions

Does using const make a function pure?

No. const prevents reassignment of the variable, but it does not prevent mutation of objects or arrays. A function can be impure even if all its variables are declared with const.

Are array methods like map and filter pure?

The methods themselves are pure when used with a pure callback. arr.map(n => n * 2) returns a new array and does not mutate the original. But arr.map(n => { console.log(n); return n; }) is impure because of the side effect.

Should I rewrite my entire codebase to use only pure functions?

No. Gradually refactor the parts that cause the most bugs or are hardest to test. Focus on data transformation logic first. Leave DOM manipulation, network calls, and event handlers impure.

Conclusion

Writing pure functions is a habit, not a switch. Return new values instead of mutating inputs. Pass dependencies as arguments instead of reaching for global state. Isolate side effects at the edges of your program. Every small step toward purity makes your code easier to test, debug, and reason about.