Should You Care About Immutability in JavaScript?
By: Rajat Kumar
Tags: Nodejs, JavaScript, Software Development, Programming, React

Things Are About to Get Serious Now (a Small Detour)

Initially, I decided to not cover this, and you can absolutely feel free to skip this section. But then, it’ll be great to know the underlying technique that Immer and several other libraries use to make our lives easier.

Creating and managing copies of data structures is an old problem, and there exist several DS implementations and some very powerful techniques which optimise this process.

Data structures

Immutable DS — Once you create, the state never changes. Partially persistent DS — All the previous versions of the state are maintained. You can read all the previous states but can only modify the most recent one. Persistent DS — You will have access to all previous versions of the state and can modify any version. This can be thought of as version control, where you can go back in time and see the previous states and can also modify things. Wikipedia describes them as:

“In computing, a persistent data structure is a data structure that always preserves the previous version of itself when it is modified. Such data structures are effectively immutable, as their operations do not (visibly) update the structure in-place, but instead always yield a new updated structure.”

Copy-on-write — “is a resource-management technique used to efficiently implement a ‘duplicate’ or ‘copy’ operation on modifiable resources. If a resource is duplicated but not modified, it is not necessary to create a new resource; the resource can be shared between the copy and the original. Modifications must still create a copy, hence the technique: the copy operation is deferred until the first write. By sharing resources in this way, it is possible to significantly reduce the resource consumption of unmodified copies, while adding a small overhead to resource-modifying operations.” Path Copying — “With the path copying method, a copy of all nodes is made on the path to any node which is about to be modified. These changes must then be cascaded back through the data structure. All nodes that pointed to the old node must be modified to point to the new node instead. These modifications cause more cascading changes, and so on, until the root node is reached.” Structural Sharing — To understand this, first we must be aware of the Trie data structure. Trie is a special kind of a tree data structure. Take a look at the following image from Wikipedia.

From Wikipedia. A trie for keys “A”, “to”, “tea”, “ted”, “ten”, “i”, “in”, and “inn”.

In a binary search tree, each internal node stores the key associated with that node for traversal. But in a Trie, no node stores the key representing that node. The position of the node in the tree defines the key with which it is associated. As per Wikipedia:

“All the descendants of a node have a common prefix of the string associated with that node, and the root is associated with the empty string. Keys tend to be associated with leaves, though some inner nodes may correspond to keys of interest. Hence, keys are not necessarily associated with every node.”

To practically understand this, let’s take an example of an array represented as a Trie, and let’s try to modify the value at a certain index.

let cities = ['Amsterdam', 'Berlin', 'Delhi', 'London', 'Chicago', 'Toronto', 'Mumbai', 'Munich']

Image source: Author

The address for city and index 1 is “001” from the root node. 0 means the left node and 1 means the right node. This way each leaf node can be indexed and will have a unique address.

Representing/storing the array as a Trie makes it possible to make changes in a persistent manner. For example, if we have to change the last city from “Munich” to “Sydney”, we can re-use the old Trie structure and modify the value at the last index (111). This is really efficient since we are not making a new copy of the entire structure. Instead, we are changing a specific part of the structure and still referencing the old structure for the non-changed values.

The values at the non-changed index can still be accessed from the old root. The structure with the new root contains the new city that we have added. After modification, our structure will look something like below.

Image source: Author

And, in a nutshell, this what structural sharing is.

A similar technique can be applied to objects to produces copies. In an object, we get a numerical hash for each key and use that to create the next version/state of our data.

hash('name') = 034334 // hash value will be used as address in Trie

hash('role') = 045356 // hash value will be used as address in Trie

Branching factor and Time Complexity

In the example above, we have seen that each node is divided into two branches. But in practical situations, large arrays (or objects) will result in a very deep nested tree and hence the time for modification will increase as well. A good balance for the branching factor and performance has been proven to be 32. An array of one billion elements in Clojure will go only 6 nodes deep.

Creating a new array and changing an element would take O(n) time. But with structural sharing and using Tries, this can be reduced to O(log(branching_factor) n), and since the branching factor is a constant, it would mean the time complexity will O(log n).

Fun Fact — Tries are very fast in retrieving the data, and that is also where the name comes from. Google uses them in the autocomplete feature in the Google search.

To conclude our (not so) small detour, these powerful techniques have been leveraged by Immer and several other libraries so that we do not have to worry much about the performance while maintaining immutability.