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.
Jump ahead:
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:
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 baseState
. nextState
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.
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.
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.
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:
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.
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
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.
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:
yarn add structurajs
You can also include the library in your web projects via a CDN, such as JSDeliver:
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/umd.min.js"></script>
Structura provides the production function that allows us to create and modify immutable data structures. Here is an example of how to use produce
:
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!
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
):
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:
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:
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.
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.
There’s no doubt that frontends are getting more complex. As you add new JavaScript libraries and other dependencies to your app, you’ll need more visibility to ensure your users don’t run into unknown issues.
LogRocket is a frontend application monitoring solution that lets you replay JavaScript errors as if they happened in your own browser so you can react to bugs more effectively.
LogRocket works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Build confidently — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowExplore use cases for using npm vs. npx such as long-term dependency management or temporary tasks and running packages on the fly.
Validating and auditing AI-generated code reduces code errors and ensures that code is compliant.
Build a real-time image background remover in Vue using Transformers.js and WebGPU for client-side processing with privacy and efficiency.
Optimize search parameter handling in React and Next.js with nuqs for SEO-friendly, shareable URLs and a better user experience.