TypeOfNaN

Mutation isn't always bad in JavaScript

Nick Scialli November 24, 2021🚀🚀 9 minute read

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.

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.

What is object mutation?

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?

Where mutation goes awry

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.

Mutability and popular front-end frameworks

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.

When is mutation good?

Now that I have discussed some scenarios in which mutation often gets blamed for buggy behavior, let’s talk about when mutation really shines.

Clarity

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.

Working with complex structures

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.

Key takeaways about object 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).

If you'd like to support this blog by buying me a coffee I'd really appreciate it!

Nick Scialli

Nick Scialli is a software engineer at the U.S. Digital Service.