We don't need mutations
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: "Lou", 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);
// Lou's will be 32console.log(`${nextYear.name}'s will be ${nextYear.age}`);
// Lou'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 arrayaddFooBar(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.