Deep JavaScript
Please support this book: buy it or donate
(Ad, please don’t block.)

8 The problems of shared mutable state and how to avoid them



This chapter answers the following questions:

8.1 What is shared mutable state and why is it problematic?

Shared mutable state works as follows:

Note that this definition applies to function calls, cooperative multitasking (e.g., async functions in JavaScript), etc. The risks are similar in each case.

The following code is an example. The example is not realistic, but it demonstrates the risks and is easy to understand:

function logElements(arr) {
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}

function main() {
  const arr = ['banana', 'orange', 'apple'];

  console.log('Before sorting:');
  logElements(arr);

  arr.sort(); // changes arr

  console.log('After sorting:');
  logElements(arr); // (A)
}
main();

// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'

In this case, there are two independent parties:

logElements() breaks main() and causes it to log an empty Array in line A.

In the remainder of this chapter, we look at three ways of avoiding the problems of shared mutable state:

In particular, we will come back to the example that we’ve just seen and fix it.

8.2 Avoiding sharing by copying data

Copying data is one way of avoiding sharing it.

  Background

For background on copying data in JavaScript, please refer to the following two chapters in this book:

8.2.1 How does copying help with shared mutable state?

As long as we only read from shared state, we don’t have any problems. Before modifying it, we need to “un-share” it, by copying it (as deeply as necessary).

Defensive copying is a technique to always copy when issues might arise. Its objective is to keep the current entity (function, class, etc.) safe:

Note that these measures protect us from other parties, but they also protect other parties from us.

The next sections illustrate both kinds of defensive copying.

8.2.1.1 Copying shared input

Remember that in the motivating example at the beginning of this chapter, we got into trouble because logElements() modified its parameter arr:

function logElements(arr) {
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}

Let’s add defensive copying to this function:

function logElements(arr) {
  arr = [...arr]; // defensive copy
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}

Now logElements() doesn’t cause problems anymore, if it is called inside main():

function main() {
  const arr = ['banana', 'orange', 'apple'];

  console.log('Before sorting:');
  logElements(arr);

  arr.sort(); // changes arr

  console.log('After sorting:');
  logElements(arr); // (A)
}
main();

// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'
// 'apple'
// 'banana'
// 'orange'
8.2.1.2 Copying exposed internal data

Let’s start with a class StringBuilder that doesn’t copy internal data it exposes (line A):

class StringBuilder {
  _data = [];
  add(str) {
    this._data.push(str);
  }
  getParts() {
    // We expose internals without copying them:
    return this._data; // (A)
  }
  toString() {
    return this._data.join('');
  }
}

As long as .getParts() isn’t used, everything works well:

const sb1 = new StringBuilder();
sb1.add('Hello');
sb1.add(' world!');
assert.equal(sb1.toString(), 'Hello world!');

If, however, the result of .getParts() is changed (line A), then the StringBuilder ceases to work correctly:

const sb2 = new StringBuilder();
sb2.add('Hello');
sb2.add(' world!');
sb2.getParts().length = 0; // (A)
assert.equal(sb2.toString(), ''); // not OK

The solution is to copy the internal ._data defensively before it is exposed (line A):

class StringBuilder {
  this._data = [];
  add(str) {
    this._data.push(str);
  }
  getParts() {
    // Copy defensively
    return [...this._data]; // (A)
  }
  toString() {
    return this._data.join('');
  }
}

Now changing the result of .getParts() doesn’t interfere with the operation of sb anymore:

const sb = new StringBuilder();
sb.add('Hello');
sb.add(' world!');
sb.getParts().length = 0;
assert.equal(sb.toString(), 'Hello world!'); // OK

8.3 Avoiding mutations by updating non-destructively

We can avoid mutations if we only update data non-destructively.

  Background

For more information on updating data, see §7 “Updating data destructively and non-destructively”.

8.3.1 How does non-destructive updating help with shared mutable state?

With non-destructive updating, sharing data becomes unproblematic, because we never mutate the shared data. (This only works if everyone that accesses the data does that!)

Intriguingly, copying data becomes trivially simple:

const original = {city: 'Berlin', country: 'Germany'};
const copy = original;

This works, because we are only making non-destructive changes and are therefore copying the data on demand.

8.4 Preventing mutations by making data immutable

We can prevent mutations of shared data by making that data immutable.

  Background

For background on how to make data immutable in JavaScript, please refer to the following two chapters in this book:

8.4.1 How does immutability help with shared mutable state?

If data is immutable, it can be shared without any risks. In particular, there is no need to copy defensively.

  Non-destructive updating is an important complement to immutable data

If we combine the two, immutable data becomes virtually as versatile as mutable data but without the associated risks.

8.5 Libraries for avoiding shared mutable state

There are several libraries available for JavaScript that support immutable data with non-destructive updating. Two popular ones are:

These libraries are described in more detail in the next two sections.

8.5.1 Immutable.js

In its repository, the library Immutable.js is described as:

Immutable persistent data collections for JavaScript which increase efficiency and simplicity.

Immutable.js provides immutable data structures such as:

In the following example, we use an immutable Map:

import {Map} from 'immutable/dist/immutable.es.js';
const map0 = Map([
  [false, 'no'],
  [true, 'yes'],
]);

// We create a modified version of map0:
const map1 = map0.set(true, 'maybe');

// The modified version is different from the original:
assert.ok(map1 !== map0);
assert.equal(map1.equals(map0), false); // (A)

// We undo the change we just made:
const map2 = map1.set(true, 'yes');

// map2 is a different object than map0,
// but it has the same content
assert.ok(map2 !== map0);
assert.equal(map2.equals(map0), true); // (B)

Notes:

8.5.2 Immer

In its repository, the library Immer is described as:

Create the next immutable state by mutating the current one.

Immer helps with non-destructively updating (potentially nested) plain objects, Arrays, Sets, and Maps. That is, there are no custom data structures involved.

This is what using Immer looks like:

import {produce} from 'immer/dist/immer.module.js';

const people = [
  {name: 'Jane', work: {employer: 'Acme'}},
];

const modifiedPeople = produce(people, (draft) => {
  draft[0].work.employer = 'Cyberdyne';
  draft.push({name: 'John', work: {employer: 'Spectre'}});
});

assert.deepEqual(modifiedPeople, [
  {name: 'Jane', work: {employer: 'Cyberdyne'}},
  {name: 'John', work: {employer: 'Spectre'}},
]);
assert.deepEqual(people, [
  {name: 'Jane', work: {employer: 'Acme'}},
]);

The original data is stored in people. produce() provides us with a variable draft. We pretend that this variable is people and use operations with which we would normally make destructive changes. Immer intercepts these operations. Instead of mutating draft, it non-destructively changes people. The result is referenced by modifiedPeople. As a bonus, it is deeply immutable.

assert.deepEqual() works because Immer returns plain objects and Arrays.