23
Mutation isn't always bad in JavaScript
We humans like dealing in absolutes. It's easy. Nuance is hard. Unfortunately for us, everything involves nuance. That's why we should question ourselves if we start to wonder if mutation is always bad.
Hey all, if you enjoy this article I’d love if you signed up for my free, weekly newsletter. 1,500+ other developers have already signed up and are leveling up their web dev skills with me!
The truth is mutation isn't always bad, nor is it usually bad. It just is. It's an excellent tool some languages give us to manipulate objects. Like with any tool, it's our responsibility to use it correctly.
Here's a quick refresher on object mutation. Let's say we have a person object:
const person = { name: 'Jarvis', age: 32 };
If we were to change this person's age, we will have mutated the object:
person.age = 33;
This seems innocuous, right?
Programming is all about communication and expectations. Mutation goes awry when the intent of an operation isn't clearly communicated and when a developer's (or machine's) expectations are violated.
Let's consider the following (bad) use of mutation:
function copyPerson(person, newName, newAge) {
const newPerson = person;
newPerson.name = newName;
newPerson.age = newAge;
return newPerson;
}
Why is this bad? Well let's look at what happens when we use this function in the wild:
const jarvis = { name: 'Jarvis', age: 32, arms: 2, legs: 2 };
const stanley = copyPerson(jarvis, 'Stanley', 27);
console.log(stanley);
// { age: 27, arms: 2, legs: 2, name: "Stanley" }
console.log(jarvis);
// { age: 27, arms: 2, legs: 2, name: "Stanley" }
Out expectations have been thoroughly violated!
In our copyPerson
function, we accidentally assigned newPerson
a reference to the same person
object. Since they reference the same object, mutating newPerson
also mutates person
.
How do we fix this? We can do it entirely without mutation by copying the person
object using the spread operator and simultaneously overwriting the name
and age
properties:
function copyPerson(person, newName, newAge) {
const newPerson = {
...person,
name: newName,
age: newAge,
};
return newPerson;
}
And that will work! But we can also make it work with mutation, and this is totally fine. Some might even find it more readable!
function copyPerson(person, newName, newAge) {
const newPerson = { ...person };
newPerson.name = newName;
newPerson.age = newAge;
return newPerson;
}
So wait, if this is fine, was mutation actually the culprit? No, it wasn't. It was our lack of understanding about how references work.
Popular front-end frameworks like React use references for render logic. Let's consider the following example:
function App() {
const [person, setPerson] = useState({ name: 'Jarvis', age: 32 });
return <PersonCard person={person} />;
}
In this example, the PersonCard
component will re-render if person
changes.
Actually, let's be more careful in our wording here: the PersonCard
component will re-render person
references a new object. Again, we can get ourselves in trouble if we mutate person
rather than creating a new object.
For this reason, the following code will be buggy:
function App() {
const [person, setPerson] = useState({ name: 'Jarvis', age: 32 });
function incrementAge() {
person.age++;
setPerson(person);
}
return (
<>
<PersonCard person={person} />
<button onClick={incrementAge}>Have a birthday</button>
</>
);
}
If we click the "Have a birthday" button, we increment the age
property of the person
object and then try to set the person
state to that object. The problem is that it's not a new object, it's the same person
object as the prevous render! React's diffing algorithm sees no change to the person
reference and doesn't re-render the PersonCard
.
How do we fix this? You guessed it: we just have to make sure we create a new object based on person
. Then, we can either accomplish the task by mutating the new object or some other means:
function App() {
const [person, setPerson] = useState({ name: 'Jarvis', age: 32 });
function incrementAge() {
const newPerson = { ...person };
newPerson.age++;
setPerson(newPerson);
}
return (
<>
<PersonCard person={person} />
<button onClick={incrementAge}>Have a birthday</button>
</>
);
}
If your instinct here is that mutating newPerson
is bad because we're using React, make sure to check your assumptions! There's nothing wrong here: newPerson
is a variable scoped to the incrementAge
function. We're not mutating something React is tracking, and therefore the fact that we're "in React" doesn't come into play here.
Again, it's very important to recognize here that mutation isn't bad. Our misunderstanding of object references and the React diffing algorithm are what caused the buggy behavior here.
Now that I have discussed some scenarios in which mutation often gets blamed for buggy behavior, let's talk about when mutation really shines.
Often, I find mutation to be clearer. One example I like to use is if we need to create a new array with one of the elements in the array updated. When working in React, I have often seen the following:
function updateItem(index, newValue) {
const newItems = items.map((el, i) => {
if (i === index) {
return newValue;
}
return el;
});
setItems(newItems);
}
And this works fine, but it's kind of confusing and probably a bit challenging to read for someone who isn't fluent in JavaScript array methods.
A more readable alternative, in my opinion, is to simply create a copy of the initial array and then mutate the appropriate index of the copied array:
function updateItem(index, newValue) {
const newItems = [...items];
newItems[index] = newValue;
setItems(newItems);
}
I think that's a lot clearer.
One of my favorite examples of where mutability shines is building a tree structure. You can do this in O(n) time all thanks to references and mutation.
Consider the following array thay represents a flattened tree:
const data = [
{ id: 56, parentId: 62 },
{ id: 81, parentId: 80 },
{ id: 74, parentId: null },
{ id: 76, parentId: 80 },
{ id: 63, parentId: 62 },
{ id: 80, parentId: 86 },
{ id: 87, parentId: 86 },
{ id: 62, parentId: 74 },
{ id: 86, parentId: 74 },
];
Each node has an id
and then the id
of its parent node (parentId
). Our code to build a tree can be as follows:
// Get array location of each ID
const idMapping = data.reduce((acc, el, i) => {
acc[el.id] = i;
return acc;
}, {});
let root;
data.forEach((el) => {
// Handle the root element
if (el.parentId === null) {
root = el;
return;
}
// Use our mapping to locate the parent element in our data array
const parentEl = data[idMapping[el.parentId]];
// Add our current el to its parent's `children` array
parentEl.children = [...(parentEl.children || []), el];
});
How this works is we first loop through the data
array once to create a mapping of where each element is in the array. Then, we do another pass through the data
array and, for each element, we use the mapping to locate its parent in the array. Finally, we mutate the parent's children
property to add the current element to it.
If we console.log(root)
, we end up with the full tree:
{
id: 74,
parentId: null,
children: [
{
id: 62,
parentId: 74,
children: [{ id: 56, parentId: 62 }, { id: 63, parentId: 62 }],
},
{
id: 86,
parentId: 74,
children: [
{
id: 80,
parentId: 86,
children: [{ id: 81, parentId: 80 }, { id: 76, parentId: 80 }],
},
{ id: 87, parentId: 86 },
],
},
],
};
That's really nifty and rather challenging to accomplish without mutation.
Over time, I have come to realize that there are a few key points to understand with respect to mutation:
- Often we blame mutation for our own lack of understanding about how references work.
- Popular front-end frameworks like React rely on comparing object references for render logic. Mutating older versions of state causes all sorts of headaches and hard-to-understand bugs. Instead of recognizing the nuance, developers will often avoid mutation entirely anywhere within React code.
- Mutation is an excellent tool when its usage is clearly communicated.
- Mutation is an excellent tool if localized (e.g., the mutated object never escapes a function).
23