Immutable Update Patters in JavaScript: Update Objects and Arrays

In this short post, well look at pure JS ways to, add, remove, and update deeply nested properties in Objects, Arrays


Well, in JavaScript, this is. Unlike most trends in the world of JavaScript, code immutability is bound to stick around us for a while, and for good reason: firstly, because it’s not a trend. It’s a way of coding (and thinking about code) that promotes clarity, ease of use and understanding data flow, and makes code less prone to errors.

While the new syntax of JS gives us a more robust toolset to work with than ever before, without the use of libraries like Immutable.js, things can still look a little bit scary when you put it all together, hence why getting comfortable with reading and
writing the most common use cases is very helpful.

In this short post, we’ll look at pure JS ways (ES2015++, and yes, I may have just invented this notation) to, add, remove, and update deeply nested properties in Objects, Arrays, and finding common patterns to reproduce these operations.
. . .

Objects

Let’s take an initial object, person, which we can think of as being a piece of application state, that we want to change. À là redux thinking, we should always return a new copy of this object and never change it directly.
const person = { name: 'Ricardo', location: 'Berlin', interests: { coffee: 9, climbing: 9, wasps: 0 } };

Changing a simple object property

Modifying a top level property is remarkably simple using Object.assign. We’ll explore more of its use cases (and alternatives) in a little bit, but for now let’s simply create a modified copy of our object with name set to “Douglas”.
const updatedPerson = Object.assign({}, person, { name: 'Douglas' });

Simples. We’re telling Object.assign to take this empty {}, apply person on top, and modify the name property. The rest of our object looks the same.

Changing deeply nested properties

Here’s a common mistake when using Object.assign to copy an object: forgetting to copy the inner objects we’re trying to mutate. Let’s say we want to change the coffee: interest to 10 and location from “Berlin” to the “Moon” (a common train route from Berlin. It takes a while). What if we try the following application:
const updated = Object.assign({}, person, { location: 'Moon', interests: { coffee: 10 // Crap! Only this one is copied } });

On the surface, it might seem like this works, but this doesn’t copy the rest of the interests object. It will leave us with an updated {coffee: 10} and location: 'Moon', but it won’t copy climbing or wasps. No one needs wasps, anyway. But how do we solve this?

Instead, we need to also deeply copy the interests object, like so:
const updated = Object.assign({}, person, { location: 'Moon', interests: Object.assign({}, person.interests, { coffee: 10 // All other interests are copied }) });

Notice the double Object.assign. A bit verbose, in truth, as all objects need to be assigned in order not to lose properties.

Spread operators

We can make this look more tidy by making use of the spread operator, which takes the form of ... — in fact, the previous example can be re-written as:
const updated = { ...person, interests: { ...person.interests, coffee: 10, } }
Much nicer to look at! Spread operators are so incredible that you should definitely read more about them at MDN.

Deleting properties

Now, onto deleting (or removing) properties from objects. The delete keyword is a mutating action, so we can’t use it when we’re thinking about immutable data.

There’s a few different ways to go around it, some more efficient than others. One approach is to recreate our entire object, but ignoring the properties we want to be removed. Let’s create a function that accepts our object, and the name of the property we would like to see removed:
const removeProperty = (obj, property) => { return Object.keys(obj).reduce((acc, key) => { if (key !== property) { return {...acc, [key]: obj[key]} } return acc; }, {}) }

It looks a bit convoluted, but what’s happening is pretty simple: for each key that is not the one we passed, we keep adding it to the accumulator, returned by the reduce function. So now, if we wanted the interests property removed from our person object, we can use this like so:
const updated = removeProperty(person, 'interests');

Which would give us a brand new copy of the object, except for that property:
{ name: 'Ricardo', location: 'Berlin', }

. . .
Common Mistake #1: New variables that point to the same objects
Defining a new variable does not create a new actual object - it only creates another reference to the same object. An example of this error would be:
function updateNestedState(state, action) { let nestedState = state.nestedState // ERROR: this directly modifies the existing object reference - don't do this! nestedState.nestedField = action.data return { ...state, nestedState } }

This function does correctly return a shallow copy of the top-level state object, but because the nestedState variable was still pointing at the existing object, the state was directly mutated.

Common Mistake #2: Only making a shallow copy of one level
Another common version of this error looks like this:
function updateNestedState(state, action) { // Problem: this only does a shallow copy! let newState = { ...state } // ERROR: nestedState is still the same object! newState.nestedState.nestedField = action.data return newState }

Doing a shallow copy of the top level is not sufficient - the nestedState object should be copied as well.


Correct Approach: Copying All Levels of Nested Data
Unfortunately, the process of correctly applying immutable updates to deeply nested state can easily become verbose and hard to read. Here's what an example of updating state.first.second[someId].fourth might look like:
function updateVeryNestedField(state, action) { return { ...state, first: { ...state.first, second: { ...state.first.second, [action.someId]: { ...state.first.second[action.someId], fourth: action.someValue } } } } }

Obviously, each layer of nesting makes this harder to read, and gives more chances to make mistakes. This is one of several reasons why you are encouraged to keep your state flattened, and compose reducers as much as possible.
. . .

Inserting and Removing Items in Arrays

Normally, a Javascript array's contents are modified using mutative functions like push, unshift, and splice. Since we don't want to mutate state directly in reducers, those should normally be avoided. Because of that, you might see "insert" or "remove" behavior written like this:
function insertItem(array, action) { return [ ...array.slice(0, action.index), action.item, ...array.slice(action.index) ] } function removeItem(array, action) { return [...array.slice(0, action.index), ...array.slice(action.index + 1)] }

However, remember that the key is that the original in-memory reference is not modified. As long as we make a copy first, we can safely mutate the copy. Note that this is true for both arrays and objects, but nested values still must be updated using the same rules.

This means that we could also write the insert and remove functions like this:
function insertItem(array, action) { let newArray = array.slice() newArray.splice(action.index, 0, action.item) return newArray } function removeItem(array, action) { let newArray = array.slice() newArray.splice(action.index, 1) return newArray }

The remove function could also be implemented as:
function removeItem(array, action) { return array.filter((item, index) => index !== action.index) }

Updating an Item in an Array

Updating one item in an array can be accomplished by using Array.map, returning a new value for the item we want to update, and returning the existing values for all other items:
function updateObjectInArray(array, action) { return array.map((item, index) => { if (index !== action.index) { // This isn't the item we care about - keep it as-is return item } // Otherwise, this is the one we want - return an updated value return { ...item, ...action.item } }) }

. . .

Conclusion

Even if you’re using an immutable library like Immutable.js or the fp flavour of lodash, it’s still a great idea to have a good grasp of how immutability works with simple JavaScript. Immutable.js comes at a rather hefty weight as a dependency, and there are alternatives such as the also popular dot-prop-immutable.

When should you use a library for immutability or go raw with JavaScript? That really depends on the complexity of your data changes, the amount of overhead you can bring both to your codebase and to your team (it’s yet another thing to learn). I’d argue that knowing the barebones implementation of most patterns is useful to understand, especially when using Redux or any other similar pattern that thrives in code immutability.

Very useful resources:
Gufran Mirza

Mar 14 2019

Write your response...

On a mission to build Next-Gen Community Platform for Developers