Immutable Data Structures in JavaScript: Embracing Change Without Changing the Past

Photo by Clint Adair on Unsplash

Immutable Data Structures in JavaScript: Embracing Change Without Changing the Past

Learn how tree-like structures can help manage complex data efficiently while preserving state history.

In this universe of JavaScript, immutability is a concept that plays a critical role in writing predictable and bug-free code. Often times, these are concepts that shows how attentive you are as a software engineer that strives to write great code which traverses through reducing complexities in managing state and debugging. In this article, we will explore what immutability is, why it's important, how you can apply it in your JavaScript projects, and also look at the library Immutable.js to manage complex state with better efficiency and performance. We will site a real-world use-case examining adding, updating, and removing items from a shopping cart, while showing how using a immutable structure could help you improve the code flow.

Here are a few concepts you'll need to have knowledge on in order to flow along in this article:

  • A basic knowledge of javaScript: we will be writing some javascript code and we assume you know a little on it already (you don't need to be an expert).

  • ES6 JavaScript syntax and conventions

What is Immutability?

Immutability as a term refers to the idea that once a data structure is created, it cannot be changed. Instead of modifying the original object initially created, you create a new object with the updated value. This is particularly useful in functional programming, where side effects (unintended changes to data) are avoided.

Benefits of Immutability

  1. Predictability: Since the objects are not modified, the same function call with the same arguments will always return the same result.

  2. Debugging: It's easier to track changes in your application when you know that objects are immutable.

  3. Concurrency: Immutable objects can be shared across multiple threads without worrying about synchronisation issues. Immutability makes sure that operations interacting at once with a single state in your application do not interfere with each other.

When to Use Immutability

Immutability is especially beneficial in situations where you want to prevent unintended side effects. This includes:

  • State Management: In applications using frameworks like React, where the state should be predictable and manageable.

  • Functional Programming: Where functions should not have side effects and should always produce the same output for the same input.

  • Versioning: When creating objects that may need to still remember and maintain their history or different versions of how they were in the past.

Managing a Shopping Cart State

In this section, we'll cover a basic shopping cart state where you can add items, remove items, delete items, and modify items in the cart. We'll look at how immutability can help us write better code, and be used to manage the state of the shopping cart effectively.

Initial Cart Setup

we'll create an array representing our items in the shopping cart. With this array of shopping objects in it, after which we begin looking at how to perform several actions on it:

// initial Shopping cart example
const shoppingCart = [
  { id: 1, name: "Apple", quantity: 2, price: 0.5 },
  { id: 2, name: "Banana", quantity: 3, price: 0.3 },
];

Exploring Mutability

First to understand immutability, let's explore how we could have done this without immutability, let us update our cart and add a new object to our shoppingCart array:

// Add Item following a mutable data structure
const addItem = (cart, newItem) => {
  shoppingCart.push(newItem); // This mutates the original cart
  return cart;
};
const updatedShoppingCart = addItem(shoppingCart, {
  id: 3,
  name: "Orange",
  quantity: 1,
  price: 0.7,
});
console.log(shoppingCart); // Original cart is mutated

// Result ==> [shoppingCart = [
//  { id: 1, name: "Apple", quantity: 2, price: 0.5 },
//  { id: 2, name: "Banana", quantity: 3, price: 0.3 },
//  { id: 3, name: "Orange", quantity: 1, price: 0.7,}
//  ]

You will notice that in this instance, anytime we add a new item to our shopping cart array, the original array always mutates (changes).

Now, let's explore how we can do the same thing with an immutable data structure:

Exploring Immutability

Using Array.prototype.concat() or the spread operator, you can add an item without mutating the original ShoppingCart:

// Add item Immutably

const addItemImmutable = (cart, newItem) => {
  return [...cart, newItem]; // This creates a new array
}

const updatedCartImmutable = addItemImmutable(shoppingCart, {
  id: 3,
  name: "Orange",
  quantity: 1,
  price: 0.7,
});
console.log(shoppingCart); // Original cart should remain unchanged
console.log(updatedCartImmutable); // result would be a new cart array with the added item

In the code block above, we made use of the spread operator to return a new cart array with the new item attached to it. This way, we don't have to directly mutate the original shoppingCart. This means that if we decide to console.log() the shoppingCart, we get the original value as it originally was.

Next, let's look at how to remove an item from the immutable shoppingCart that we updated:

Removing An Item With Immutability

To remove an item we'll be using the javaScript filter() method to filter out the item we want to remove based on its id

// Removing an item with immutability
const removeItemImmutable = (cart, itemId) => {
    return cart.filter(item => item.id !== itemId);
  }

  const cartAfterRemoval = removeItemImmutable(updatedCartImmutable, 1);
  console.log(cartAfterRemoval); // Item with id 1 is removed
  console.log(updatedCartImmutable); // Original updated cart remains unchanged

Here with the array method filter(), we successfully removed the item with the item id 1 from our cart, and held the new cart array in a different state.

Updating An Item With Immutability

To update an item that already exists, you can map over the cart array and update the specific item object. In the code block below, let us update the item order quantity of a specific item based on the item id:

// Update an item with immutability
const updateItemQuantityImmutable = (cart, itemId, newQuantity) => {
    return cart.map(item => 
      item.id === itemId ? { ...item, quantity: newQuantity } : item
    );
  }

  const cartAfterUpdate = updateItemQuantityImmutable(cartAfterRemoval, 2, 5);
  console.log(cartAfterUpdate); // Quantity of item with id 2 is updated

If you have been following so far, we have been able to create a shoppingCart, add an item to it updatedCartImmutable, remove item from cart cartAfterRemoval, and update an already existing item in the cart cartAfterUpdate, but we are still able to easily track the different states of our cart state at the different times. This is what immutability allows you to accomplish in your application. Because everything is so well defined, it reduces the amount of errors we experience, reduces debugging time, and allows us keep clear versioning which can be used to build a richer user experience by allowing us add things like undo and redo easily in our application.

Deeper Dive: Beyond Basic Immutability

While JavaScript already provides basic approaches for creating immutable data structures using several methods like Object.freeze, the spread operator, and Array.prototype.map, these approaches can become unmanageable and inefficient for complex or deeply nested data structures. Managing immutability manually can lead to boilerplate code, reduced performance, and increased potential for errors.

Limitations Of The Basic JavaScript Immutability

  • Deeply Nested Values: Manually copying and updating deeply nested objects can be tedious and error-prone, it could also lead to a less readable code because you will need to repeatedly used these in-built methods for manipulating the nesting, thereby making your code unpleasant and confusing.

  • Performance: When you create several copies of large data structures, it can lead to performance issues, especially when frequent updates are necessary.

These reasons have been thought about by very intelligent people and different libraries have been made to fix this problem. From here on, let's take a look at immutable.js and see how it provides us a more robust and eloquent way for handling immutability efficiently.

Introduction To Immutable.js

Immutable.js is a library that provides persistent immutable data structures. It offers a comprehensive set of features that make managing immutable data easier and more efficient.

Key Features of Immutable.js

  1. Persistent Data Structures: Immutable.js provides data structures like List, Map, Set, and Record, which are immutable by default.

  2. Efficient Updates: Uses structural sharing to minimize copying and optimize performance. Only the parts of the data structure that change are copied.

  3. Rich API: Offers a wide range of methods for manipulating data, similar to native JavaScript arrays and objects, but with immutability guarantees. As your application grows in complexity, immutable.js provides an extensive library to help you manage all your operations and state management without having to compromise on immutability.

  4. Flexible Adaptation: Easily integrates with existing JavaScript code and libraries, and can be used alongside native data structures.

Installation

Install immutable using package installer you use

# using npm
npm install immutable

# using Yarn
yarn add immutable

# using pnpm
pnpm add immutable

# using Bun
bun add immutable

you can simply import or require it into any module you need it in:

const { Map } = require('immutable');

// with ES6
import { Map} from "immutable"

You can also use immutable.js through CDN directly with the <script/> tag in your html file, although it is recommended to use a module bundler:

<script src="immutable.min.js"></script>

Visit the immutable-js get started guide to learn more.

Now that that is out of the way, let us look at how we can create, add items, remove items, and update an item in our shopping cart using immutable.js

Handling Our Shopping Cart With Immutable.js:

import { List, Map } from 'immutable';

// First, create an immutable list of items
let cart = List([
  Map({ id: 1, name: 'Apple', quantity: 2, price: 0.5 }),
  Map({ id: 2, name: 'Banana', quantity: 3, price: 0.3 })
]);

// Add an item to the cart example
cart = cart.push(Map({ id: 3, name: 'Orange', quantity: 1, price: 0.7 }));

// Update an item in the cart example
cart = cart.update(
  cart.findIndex(item => item.get('id') === 2),
  item => item.set('quantity', 5)
);

// Remove an item from the cart example
cart = cart.filter(item => item.get('id') !== 1);

console.log(cart.toJS()); // Convert to native JavaScript object for viewing

In the code block above we imported the List and Map data structure from the immutable library.

  • List work just like JavaScript array. They are ordered indexed dense collections. There are different methods that can be called on the List, the ones we use above include .push() which returns a new list with the provided value appended, .update() is used to apply a change to an elememt or item at a specific index in the list, .filter() method is used to create a new list that includes only the elements that pass the test implemented by the provided function.

  • Map Immutable Map is an unordered Collection.Keyed of (key, value) pairs (source: immutable-js.com/docs/v4.3.7/Map). It works like writing regular objects in JavaScript with key-value pairs.

Conclusion

Immutability is indeed a powerful concept that can enhance and improve the reliability, maintainability, and performance of your JavaScript application.

By leveraging tools and libraries like Immutable.js, you are able to carry out more robust operation that will be more difficult if you were to do it with the foundational tools JavaScript provides. Depending of the scale of what you're building, using a library like this would prove more effective to your usecase in managing more complex state and nested structures. Our aim is to create not only a beautiful body of work, but to make it easier to debug, thereby improving the developer experience as well.

  • Check out all the examples used here on GitHub

Related Resources