Andy Van Slaars

Andy Van Slaars

moon indicating dark mode
sun indicating light mode

On ImmutableJS

this is something I wrote up a while back for folks I work with and wanted to capture it here.

The rationale for moving away from ImmutableJS

  • ImmutableJS data structures make it difficult to operate with standard JS data types. This means values that are not already ImmutableJS types need to be converted into ImmutableJS types or the ImmutableJS types need to be converted into standard JS types.
    • Converting between ImmutableJS and JS is a relatively expensive operation, because of this, ImmutableJS will spread throughout a codebase, spreading the potential issues it brings with it.
    • If you’re using any 3rd party React components (including react-magma) that expect standard JS types or trying to use a utility library such as lodash or Ramda, converting between ImmutableJS and JS will be unavoidable.
    • There is additional cognitive load and a learning curve for anybody joining a team without ImmutableJS experience
    • ImmutableJS can perform faster than using native JS features for manipulating immutable data structures, but frequent conversions between ImmutableJS and standard JS negate those benefits.
  • Converting an ImmutableJS object to JS always creates a new object instance. In React, this means using a shallow equality check on the result of toJS will not prevent rendering, even when nothing has changed.
    • This can lead to many unnecessary renders in addition to the potentially expensive toJS call.
    • If this is done in concert with a Redux store, say in mapPropsToState, those renders will propagate all the way down the component tree, even in cases where rendering would have otherwise been skipped.
  • Using ImmutableJS means you opt out of the benefits provided by TypeScript
    • Without ImmutableJS, accessing a deeply nested property of an object instance such as customer.address.coordinates.latitude can be type checked and assisted by IntelliSense while coding.
    • With ImmutableJS, the same property would be accessed like customerMap.getIn(['address', 'coordinates', 'latitude']), using strings to access those values means TS can’t statically analyze this code and check your types.
    • When converting to standard objects with toJS, the resulting value is assigned a TypeScript any type, so even if it is structurally identical to something that you have typed, the TS compiler won’t be able to infer that relationship.
    • It should be noted that even in projects with all JS code, editors like VSCode can use the TypeScript language server to offer IntelliSense, so TS can be beneficial even if you’re not writing it.

Immutable data without ImmutableJS

If your project is using ImmutableJS, you should consider moving away from it. Below are some general guidelines for where you might start this work and some techniques for maintaining the immutable behavior using built-in JavaScript features or the Immer library.

  • Many state updates can be managed with immutable behavior using built-in features of JavaScript
  • For cases where readability could suffer from using built-in language features, Immer is a lightweight approach to immutable state updates that does not force custom data structures or API into the rest of your code.
  • For local component state, you can shift that internal state to standard JS objects one component at a time
  • For Redux state, you may find that moving state from the global store to local component state is a good opportunity to also convert to standard JS data structures.

Code examples in codesandbox

The following examples are all based on Jest unit tests and can be executed here: https://codesandbox.io/s/mystifying-albattani-zts93?fontsize=14&module=%2Fsrc%2Findex.spec.js&previewwindow=tests

Immutable objects with Object.assign and object spread

Object.assign with an empty object as the first argument will return a modified version of that first argument, merging the properties from the additional arguments. The last object in will “win”, having any keys it specifies applied to the resulting object.

test("Basic use of Object.assign", () => {
const input = { id: 1, name: "Bill" }
const expected = { id: 1, name: "William" }
const result = Object.assign({}, input, { name: "William" })
expect(result).toEqual(expected)
// Object references are different
expect(result === input).toBeFalsy()
})

Object spread is syntactic sugar for the use of Object.assign listed above.

test("Basic use of object spread", () => {
const input = { id: 1, name: "Bill" }
const expected = { id: 1, name: "William" }
const result = { ...input, name: "William" }
expect(result).toEqual(expected)
// Object references are different
expect(result === input).toBeFalsy()
})

Immutable Arrays with [].concat

test("Append an element with [].concat", () => {
const input = [1, 2, 3]
const expected = [1, 2, 3, 4]
const result = input.concat(4)
expect(result).toEqual(expected)
expect(result === input).toBeFalsy()
})
test("Prepend an element with [].concat", () => {
const input = [1, 2, 3]
const expected = [4, 1, 2, 3]
const result = [].concat(4, input)
expect(result).toEqual(expected)
expect(result === input).toBeFalsy()
})
test("combine two arrays with concat", () => {
const input = [1, 2, 3]
const expected = [1, 2, 3, 4, 5, 6]
const result = input.concat([4, 5, 6])
expect(result).toEqual(expected)
expect(result === input).toBeFalsy()
})

Immutable Arrays with spread

test("Append an element with [].concat", () => {
const input = [1, 2, 3]
const expected = [1, 2, 3, 4]
const result = [...input, 4]
expect(result).toEqual(expected)
expect(result === input).toBeFalsy()
})
test("Prepend an element with [].concat", () => {
const input = [1, 2, 3]
const expected = [4, 1, 2, 3]
const result = [4, ...input]
expect(result).toEqual(expected)
expect(result === input).toBeFalsy()
})
test("combine two arrays with concat", () => {
const input = [1, 2, 3]
const expected = [1, 2, 3, 4, 5, 6]
const result = [...input, ...[4, 5, 6]]
expect(result).toEqual(expected)
expect(result === input).toBeFalsy()
})

Immutable Arrays with built-in array methods

Several of the built-in array methods return a new array rather than mutating the existing array. For many array updates, one of these methods will serve your needs

describe("Immutable Arrays with non-mutating array methods", () => {
test("remove array elements with filter", () => {
const input = [1, 2, 3, 4, 5]
const expected = [1, 3, 5]
const result = input.filter((n) => n % 2 !== 0)
expect(result).toEqual(expected)
expect(result === input).toBeFalsy()
})
test("return a subset of array elements with slice", () => {
const input = [1, 2, 3, 4, 5]
const expected = [3, 4, 5]
const result = input.slice(2)
expect(result).toEqual(expected)
expect(result === input).toBeFalsy()
})
test("transform all array elements with map", () => {
const input = [1, 2, 3, 4, 5]
const expected = [2, 4, 6, 8, 10]
const result = input.map((n) => n * 2)
expect(result).toEqual(expected)
expect(result === input).toBeFalsy()
})
test("transform a single array element with map", () => {
const input = [1, 2, 3, 4, 5]
const expected = [1, 2, "Three", 4, 5]
const result = input.map((n) => (n === 3 ? "Three" : n))
expect(result).toEqual(expected)
expect(result === input).toBeFalsy()
})
})

Immutable Objects and Arrays with Immer

The immer library handles immutability through a copy-on-write mechanism. You pass your state into the exposed produce function along with a callback that receives a draft of the new state object. Any mutations made to the draft state are applied to the return value of produce. Immer is a nice, lightweight choice for handling state updates where the standard language features make the code hard to read and since you’re working with native data structures, you retain the benefits of TypeScript (even in a JS project, VSCode uses the TypeScript language server for IntelliSense).

Immer also has a feature where it automatically freezes objects returned from calls to produce to avoid unintended mutations elsewhere in the code. This is enabled in development builds, but stripped out in production. This way, you catch unintended mutations in your code during development but get an efficient production build.

You can use immer for simple changes, but it really shines for deeply nested object structures. Let’s compare approaches where the input and expected output look like:

const customer = {
firstName: "Bill",
lastName: "Smith",
address: {
street: "111 Elm St.",
city: "Anywhere",
state: "CA",
coordinates: {
latitude: "34.1341° N",
longitude: "118.3215° W",
},
},
}
const expected = {
firstName: "William",
lastName: "Smith",
address: {
street: "200 Oak Blvd.",
city: "Anywhere",
state: "CA",
coordinates: {
latitude: "34.1341° N",
longitude: "117.9190° W",
},
},
}

If you compare the customer object and expected object, you’ll see that we’re modifying a property at each level of this nested object to create our new object.

Immer code to create a new customer object

The immer code to accomplish this is pretty straightforward and readable:

import produce from "immer"
const result = produce(customer, (draft) => {
draft.firstName = "William"
draft.address.street = "200 Oak Blvd."
draft.address.coordinates.longitude = "117.9190° W"
})

multi-part state update with spread syntax

If we compare that to the same state update using object spread syntax, you’ll see that our readability starts to suffer:

const result = {
...customer,
firstName: "William",
address: {
...customer.address,
street: "200 Oak Blvd.",
coordinates: {
...customer.address.coordinates,
longitude: "117.9190° W",
},
},
}

Update with mutating functions from lodash

Immer also makes it straightforward to achieve immutability using utility libraries that mutate objects, like many of the functions provided by lodash.

This example isn’t the best approach to this particular update (the first immer example here is more idiomatic), but it illustrates the use of lodash’s set function, which directly mutates the object passed as its first argument.

import produce from "immer"
import _ from "lodash"
const result = produce(customer, (draft) => {
_.set(draft, "firstName", "William")
_.set(draft, "address.street", "200 Oak Blvd.")
_.set(draft, "address.coordinates.longitude", "117.9190° W")
})

Simple updates with immer

Of course, Immer can still be used for simple updates. In some cases, simple changes will be better served with mechanisms like the spread operator, but if you are using immer and want to keep your code consistent, it can be employed for the simple changes too:

test("Simple object change with immer", () => {
const input = { id: 1, name: "Bill" }
const expected = { id: 1, name: "William" }
const result = produce(input, (draft) => {
draft.name = "William"
})
expect(result).toEqual(expected)
// Object references are different
expect(result === input).toBeFalsy()
})

Return the new state value if you aren’t mutating draft

There are times when you may find the easiest way to apply an update uses non-mutating functions by default. As mentioned above, you can skip immer for those updates, but if you are using immer to stay consistent, you can use the immutable approach and just return the value you want to use as your updated state directly. Take this example of combining two arrays. The concat array method is the simplest way to do this, but it doesn’t mutate the draft value passed to produce, so you need to return the resulting value:

test("combine two arrays with immer", () => {
const input = [1, 2, 3]
const expected = [1, 2, 3, 4, 5, 6]
const result = produce(input, (draft) => {
// An explicit return results in that
// return being the value that produce returns
// No need to mutate in cases where the immutable
// operation is more straightforward
// in this case, produce doesn't add much value
// this is just for illustration purposes
return draft.concat([4, 5, 6])
})
expect(result).toEqual(expected)
expect(result === input).toBeFalsy()
})

Curried produce to create a reducer function

Immer also offers a curried overload of the produce function that is ideal for a reducer, if you’re modifying existing Redux code or using the useReducer hook offered by React starting with version 16.8:

The curried version takes a callback that defines the state update callback and returns a function that will accept state and return the updated state object.

const reducer = produce((draft, action) => {
switch (action.type) {
case "INC":
draft.count++
break
case "DEC":
draft.count--
break
default:
break
}
})
test("Reducer works with immer - INC", () => {
const startingState = { count: 0 }
const expectedState = { count: 1 }
const result = reducer(startingState, { type: "INC" })
expect(result).toEqual(expectedState)
expect(result === startingState).toBeFalsy()
})