Instructive.dev

by Cole Gawin

Get in touch
discovery

May 30, 2023

Structura.js vs. Immer.js: Comparing libraries for writing immutable state

In this article, we will compare Structura.js and Immer.js and explore their features, advantages, and disadvantages.

programming
javascript

Immutable states are a fundamental concept of modern software development, especially in JavaScript and web development. Compared to a mutable state, which involves a state that can be mutated or changed (think using the let keyword), an immutable state involves a state that should not be mutated (e.g., const keyword). As such, immutable states allow developers to write code that is more maintainable, predictable, and less prone to bugs.

In JavaScript, creating a new variable using let signifies that the variable can be reassigned a new value; while creating a new variable using const signifies that the variable is constant and cannot be reassigned a new value, allowing for a sense of immutability in JavaScript.

However, things start getting tricky when you introduce objects into the mix. Even if you create a new variable using const, if the variable is assigned with the value of an object, you can still reassign new values to the properties of the object. This contradicts the concept of immutability and makes immutable states tricky to work with in JavaScript.

This is where libraries like Structura.js and Immer.js come in handy. These libraries provide developers with powerful tools to use immutability in their JavaScript programs. In this article, we will compare these two libraries and explore their features, advantages, and disadvantages.

What is Immer.js?

First, let’s discuss Immer.js. In fact, Immer.js is one of the most popular libraries on npm, with nearly 10 million downloads a week, and is considered an industry standard — or current best practice — when using immutable states in JavaScript.

Immer.js, at its most fundamental level, allows you to work with immutable data structures by creating a draft state that you can modify as if it were mutable. The library then creates a new immutable state based on your modifications to the draft state. Here’s a basic example of using Immer to update an immutable state:

1 2 3 4 5 6 7 import produce from "immer" const baseState = [] const nextState = produce(baseState, draft => { draft.push(1) })

We take our baseState and use Immer’s produce method to update a draft version of baseStatenextState will result from draft after performing our updates on draft, thereby avoiding mutating baseState. Immer provides a draft API that allows you to modify the state as if it were mutable. This makes working with immutable state incredibly convenient.

Plus, Immer will detect accidental mutations and throw an error. Additionally, Immer supports built-in JavaScript data types, like objects, arrays, sets, and maps. Regarding structural sharing, Immer shares as much data as possible between the base state and draft state, improving performance and reducing memory usage. Immer also allows you to work with nested data structures and ensures that any changes you make to them do not affect the original state.

Advantages

Compared to other immutable state libraries for JavaScript, Immer comes with impeccable developer experience. It makes handling immutable data structures incredibly simple and efficient. In my opinion, the learning curve for Immer is very shallow since you don’t need to learn additional data structures or library-specific APIs to use it.

Immer is a tried-and-true library and has a large developer community. So, if you run into issues using the library, there is bound to be a developer online who has run into the same issue and knows a solution. There are also useful resources to start learning immutability with Immer, available in their official documentation.

Disadvantages

The main disadvantage to Immer is that it is not highly performant, especially when it comes to large objects. Under the hood, Immer uses Object.freeze during runtime to make the object read-only, which leads to performance issues when working with large objects.

Nested objects take an especially large performance hit because Object.freeze needs to be run at each level of the object. To address this, Immer has an entire resource page dedicated to improving performance while using the library.

Some edge cases require some workarounds. For example, Immer requires that you mark all custom classes as immerable with a custom property. Immer also doesn’t support circular references (for example, when a property of the object refers to the object itself); the library only supports unidirectional trees.

What is Structura.js?

Structura.js is a relatively new library designed to be nearly identical to Immer.js in terms of usability, but is much more performant. Though Structura.js and Immer.js share the draft API concept and produce a new state by updating a draft object, the way they operate under the hood is fundamentally different.

Immer uses Object.freeze during runtime to make the object read-only. Structura freezes objects at compile time with TypeScript using custom types. The performance cost of making an object immutable is reduced to zero because no work is done during runtime. It’s a fairly innovative concept. Here’s an example of using Structura to update an immutable state:

1 2 3 4 5 6 7 import { produce, Freeze } from "structurajs" const baseState = [] as Freeze<number[]> const nextState = produce(baseState, draft => { draft.push(1) })

Structura and Immer handle immutability similarly with the produce method. The only difference is that the baseState in the Structura example is marked as frozen by being assigned the Freeze type. This tells Structura to disallow any updates to baseState outside of the produce method.

Structura.js also supports circular references that other libraries (including Immer) may struggle with. Lastly, Structura.js also offers utility methods for freezing (and unfreezing) during runtime to provide cross-compatibility with JavaScript.

Advantages

The main advantage of using Structura.js is, of course, the performance benefits. It’s lightning-fast compared to libraries like Immer, which handle freezing during runtime. In fact, Structura claims to be upwards of 22x faster than Immer. Structura also handles some edge cases other libraries struggle with, like circular references.

According to its official documentation, Structura.js is advantageous for the following reasons:

  • Performance: Performance is important to you and immutable states are becoming a bottleneck in your application
  • State size: The state you have to deal with is possibly very huge and complex
  • Cut resources: In serverless functions or in the cloud, because you’d want to cut used resources as much as possible
  • References in state: Circular and multiple references may be present in your state
  • Flexibility: You prefer not being limited in the return type of the producer
  • Data modification: Modifying the draft and returning a portion of it in the same producer is needed
  • Developer friendly: You don’t want to think about enabling/disabling features you may or may not need
  • Code size: Forking the library to adapt it to your use case, because the code is small and easy enough to reason about

Disadvantages

The developer community around Structura is very small, and the library is still in alpha. This means it is less mature and stable than established libraries like Immer. Because of the library’s small community and its somewhat underdeveloped documentation, there may be some trial and error when using the library, and you may have to be more self-sufficient in resolving issues you run into.

Working with Structura.js

Now that we’ve covered what makes Structura unique from (and similar to) Immer, let’s take a look at how we can use it in practice. To use Structura.js, you need to include the library in your project. You can download the library from the official GitHub releases page or install it using a package manager like npm or Yarn:

1 yarn add structurajs

You can also include the library in your web projects via a CDN, such as JSDeliver:

1 <script src="https://cdn.jsdelivr.net/npm/structurajs@0.3.4/dist/umd.min.js"></script>

Creating immutable data structures

Structura provides the production function that allows us to create and modify immutable data structures. Here is an example of how to use produce:

1 2 3 4 5 6 7 8 9 10 11 12 13 import { produce } from "structurajs" const baseState = { todos: [ { id: 1, text: 'Learn Structura.js', completed: false }, { id: 2, text: 'Build an app with Structura.js', completed: false } ] }; const nextState = produce(baseState, draftState => { draftState.todos.push({ id: 3, text: 'Test Structura.js', completed: true }); draftState.todos[0].completed = true; });

In this example, we have an object called baseState that contains a list of todos. We then use the produce function to create a new object called nextState that represents the modified version of baseState. The produce function takes two arguments: the baseState and a producer function. Inside the producer function, we modify a draftState by adding a new todo and marking the first todo as completed.

Structura.js then produces nextState from the mutations to the draft state within the producer. The key thing to note is that we are modifying the draftState inside produce. Structura.js will create a new immutable version of the state object for us!

Implementing undo/redo functionality with patches

One neat side-effect of working with immutable data structures is that you maintain the ability to record exactly what parts of the states were changed, and in what way. These records are called patches, and can be very useful for keeping a space-efficient history of the changes made to your state.

For example, if you want to redo an edit, you can edit your state according to the patch that produces the next state. If you want to undo an edit, you can use the inverse of the previous patch. Structura provides built-in functionality for working with patches.

Using the produceWithPatches function, you can gain access to the new state, as well as the patches and inverse patches. Here’s an example of how you can use the patches functionality in Structura. First, produce the nextState and keep track of the patches (and inverse patches):

1 2 3 const [nextState, patches, inverse] = produceWithPatches(baseState, draftState => { draft.push({ id: 3, text: 'Use patches in Structura.js', completed: false }); });

Now, if we apply those patches to the original starting point baseState, we should end up with the same nextState. This is a redo functionality! Here it is:

1 2 const nextState2 = applyPatches(baseState, patches); expect(newResult2).toEqual(nextState); // true

If we apply the inverse patches to nextState, we should end up back to our original starting point baseState. This is the undo functionality:

1 2 const undone = applyPatches(newResult, inverse); expect(undone).toEqual(baseState); // true

It should also be noted that Immer.js also provides support for patches, though you must explicitly enable the feature before usage. Consult their documentation for more information.

Conclusion

If performance is of utmost importance for your application, consider using Structura.js. Unless you use the utility methods provided by the library, it adds zero overhead to your application because it does not interact with your data during runtime and will be faster than Immer in almost every situation as a result.

With that said, if you need a production-ready library with a large community, consider using Immer.js. It is a well-established library, unlike Structura.js, it is stable and ready for use in production.

Subscribe to Instructive.dev

Don’t miss out on the latest content! Join over 3k+ devs in subscribing to Instructive.dev.

Join the mailing list

HomeAboutPartnersContent
made withby Cole Gawin