We don't need mutations

6 min read

Mutations are evil

Mutations are at the core of the vast majority of bugs we have to deal with in our careers as developers. What initially looks like something as harmful as just "updating a value" can quickly turn into a mess of unpredictable states. These issues with mutations are prevalent in JavaScript and languages like it because if we pass an object to a function, that object might not be the same after that function runs:

/**
 * We have a user object with 2 properties,
 * name and age.
 */
const user = {
	name: "Luke",
	age: 31,
};

/**
 * We have a function that gives us the user with
 * the age change to the next year value (+1)
 */
const userNextYear = user => {
	user.age += 1;
	return user;
};

const nextYear = userNextYear(user);

// Luke's will be 32
console.log(`${nextYear.name}'s will be ${nextYear.age}`);

// Luke's age ... is also 32 ๐Ÿ˜ซ
console.log(`${user.name}'s age is ${user.age}`);

Now, this is obvious because all the code is in the same place. Now imagine the surprise if we import that function from somewhere else:

import { something } from "twilight-zone";

const object = { foo: "bar" };

something(object);

// No way of knowing for sure what's inside of `object` ๐Ÿ˜ตโ€๐Ÿ’ซ

How can we resolve this?

There are several approaches to resolving the issues presented by mutation, some better than others. But, unfortunately, the worst one (and one of the most common solutions) is to make a copy of the object before passing it to a function:

import { someDeepCopyUtil } from "someLibrary";
import { someUtil } from "somewhere";

const object = { foo: "bar" };
const copy = someDeepCopyUtil(object);

someUtil(copy);

// object is unaffected, yey!

The problem is that we're transferring the responsibility of avoiding mutations to the consumer of our functions. So every time someone uses those functions, they need to make sure they do a copy of the object before.

One better solution is to write our functions without mutations, returning updated copies of the received objects instead of changing them. These are called pure functions, and the action of avoiding mutations is called immutability. Going back to the first example:

const userNextYear = user => ({
	...user,
	age: user.age + 1,
});

// This returns a copy of user:
userNextYear(user);

// So this still has the original value:
user.age;

This is great for small functions that do little changes to small objects, but the problem comes with nested values, which increase the complexity greatly:

const object = {
	foo: {
		bar: [0, 1, 2, 3],
		other: {
			value: "string",
		},
	},
};

const updateOtherValue = value => object => ({
	...object,
	foo: {
		...object.foo,
		other: {
			...object.foo.other,
			value,
		},
	},
});

Which is way more complex than just doing a mutation:

const updateOtherValue = value => object => {
	object.foo.other.value = value;
	return object;
};

So developers tend to "fall back" to doing mutations or doing a copy. Luckily for us, if we want to write code as we were doing mutations, but without them, we have an excellent library for that called immer. This library allows us to write our updateOtherValue function like this:

import { produce } from "immer";

const updateOtherValue = value => object =>
	produce(object, draft => {
		draft.foo.other.value = value;
	});

So we end up with the best of both worlds: Code as simple as when we do mutations, but immutable. Now let's go back to JavaScript without libraries for a second.

Things to avoid from vanilla

JavaScript itself has some methods that aren't pure. For example, Array has a few methods in its prototype, like push or pop, that mutate the array. So we end up with similar issues to the first example:

const array = ["foo", "bar"];
const addValue = value => array => array.push(value);

const addFooBar = addValue("foobar");

// This changes the original array:
addFooBar(array); // ["foo", "bar", "foobar"]

We can either avoid impure methods:

const array = ["foo", "bar"];
const addValue = value => array => array.concat(value);

const addFooBar = addValue("foobar");

// This returns a copy of the array
addFooBar(array); // ["foo", "bar", "foobar"]
// But the original is untouched :D

Or, we can resort to immer again:

import { produce } from "immer";

const array = ["foo", "bar"];
const addValue = value => array => produce(array, draft => draft.push(value));

const addFooBar = addValue("foobar");

// Same effect as the pure approach ๐ŸŽ‰
addValue(array);

To avoid mutations in Arrays in the future, I recommend this excellent site with a comprehensive list of Array methods and a flag for when they mutate or not: doesitmutate.xyz.

Another thing to consider is that the DOM APIs, such as Element.setAttribute, are full of mutations, so if we want to change something dynamically on a WebApp we need to mutate. Luckily for us, libraries like Preact, React, Vue, and others have an abstraction layer over the DOM that makes the DOM behave in a "pure" way by letting us update its state without having to do the mutations ourselves, consistently and safely.

Common arguments in favor of mutations

If we use classes, we need mutations!

This article is in the same series as We don't need classes and is very close to it in spirit. Classes generally encourage saving and updating values inside them, so this is yet another reason to avoid classes and to use pure functions and values instead. But even if we still use classes, we should avoid mutations, by returning new instances of the classes with the new values in them.

What about performance?

JavaScript and languages like it have a great garbage collector that takes care of the values we're no longer using. So if we create a copy of an object and then only use the copy, then the memory for the original is freed. Either way, the cost in performance is way too low compared to the benefits we get from never doing mutations.

Do we need mutations?

Next time we are about to do a mutation, we should ask ourselves: Do we need to mutate that value? Don't we have a way of resolving that issue without doing mutations? I'm not saying this will always be the solution, but it should be the default.

Share this article