Introduction and Source Code Analysis of immer.js: Simpler and Faster Immutable Data Structures
In JavaScript, variable types can be roughly divided into primitive types and reference types. In practice, reference types often cause…
Introduction and Source Code Analysis of immer.js: Simpler and Faster Immutable Data Structures
In JavaScript, variable types can be roughly divided into primitive types and reference types. In practice, reference types often cause unintended side effects. Therefore, experienced developers in modern JS consciously use immutable data structures to break references at appropriate places.
// Side effects caused by references
var a = [{ val: 1 }]
var b = a.map(item => item.val = 2)
// Expected: each element's val in b becomes 2
console.log(a[0].val) // 2
As shown above, the intention was to change only the values in
b, but we unintentionally modified the values inaas well. This is not expected. Ifais used elsewhere, it can easily lead to unpredictable and hard-to-debug bugs.
After discovering this problem, the solution is simple. Generally, when passing a reference type (like an object) into a function, we can use Object.assign or the spread operator … to destructure the object and break the reference at one level.
For example, the above problem can be rewritten as:
var a = [{ val: 1 }]
var b = a.map(item => ({ ...item, val: 2 }))
console.log(a[0].val) // 1
console.log(b[0].val) // 2
However, this approach only breaks the reference at the first level. If the object is deeply nested, there is still risk.
// Deeply nested objects
var a = [{
val: 1,
desc: { text: 'a' }
}]
var b = a.map(item => ({ ...item, val: 2 }))
console.log(a === b) // false
console.log(a.desc === b.desc) // true
The result of a.desc === b.desc is still true, which means a.desc and b.desc still point to the same reference. If you accidentally modify b.desc later, it will also affect a.desc, which is not what we want.
Therefore, in most cases, we consider deep cloning to completely avoid these issues. Deep cloning means recursively creating new objects for all nested reference types.
// A simple deep clone function, omitting some glue code
// Input is a plain object, and all values are also plain objects
function deepClone(obj) {
const keys = Object.keys(obj)
return keys.reduce((memo, current) => {
const value = obj[current]
if (typeof value === 'object') {
return {
...memo,
[current]: deepClone(value),
}
}
return {
...memo,
[current]: value,
}
}, {})
}
Testing the deepClone function:
var a = {
val: 1,
desc: {
text: 'a',
},
}
var b = deepClone(a)
b.val = 2
console.log(a.val) // 1
console.log(b.val) // 2
b.desc.text = 'b'
console.log(a.desc.text) // 'a'
console.log(b.desc.text) // 'b'
The above deepClone works for simple needs, but in production, there are many more factors to consider, such as:
-
How to handle getters, setters, and prototype chain properties?
-
What if the value is a Symbol?
-
What about non-plain objects?
-
How to handle circular references?
Because of these uncertainties, it’s recommended to use well-tested utility functions from large open-source projects in real-world engineering. The most common is lodash.cloneDeep, which is reliable and safe.
The concept of removing side effects from reference types is called immutable data. More accurately, it’s about immutable relationships. When we create a deep-cloned object, any side-effect operations on the new data won’t affect the original data. This is the essence of immutability.
Here, side effects are not limited to property assignments via dot notation. Array operations like push, pop, splice, etc., also mutate the original data and are considered non-immutable.
However, deepClone, while breaking references, is expensive because it always creates new objects, even if nothing changed. In 2014, Facebook released immutable-js, which ensures immutability while optimizing performance.
Introduction to immutable-js
immutable-js uses a different set of data structure APIs. It converts all native types (Object, Array, etc.) into its own internal types (Map, List, etc.), and any operation returns a new immutable value.
The previous example with immutable-js:
const { fromJS } = require('immutable')
const data = {
val: 1,
desc: {
text: 'a',
},
}
const a = fromJS(data)
const b = a.set('val', 2)
console.log(a.get('val')) // 1
console.log(b.get('val')) // 2
const pathToText = ['desc', 'text']
const c = a.setIn([...pathToText], 'c')
console.log(a.getIn([...pathToText])) // 'a'
console.log(c.getIn([...pathToText])) // 'c'
immutable-js also has performance advantages. For example:
const { fromJS } = require('immutable')
const data = {
content: {
time: '2018-02-01',
val: 'Hello World',
},
desc: {
text: 'a',
},
}
const a = fromJS(data)
const b = a.setIn(['desc', 'text'], 'b')
console.log(b.get('desc') === a.get('desc')) // false
console.log(b.get('content') === a.get('content')) // true
const c = a.toJS()
const d = b.toJS()
console.log(c.desc === d.desc) // false
console.log(c.content === d.content) // false
As shown, deeply nested objects that are not modified retain strict equality, which is called “structural sharing.” Unchanged nested objects keep their references, and only changed parts are copied.
React developers are likely familiar with immutable-js, as it greatly improves React performance.
Of course, immutable-js is not the only way to achieve immutability. This article mainly introduces another library: immer.
Introduction to immer
immer is authored by the creator of mobx. Unlike immutable-js, immer uses native data structure APIs. For example:
const produce = require('immer')
const state = {
done: false,
val: 'string',
}
const newState = produce(state, (draft) => {
draft.done = true
})
console.log(state.done) // false
console.log(newState.done) // true
All side-effect logic can be placed inside the function passed as the second argument to produce. Any changes to draft do not affect the original state.
After this brief introduction, let’s look at its internal implementation.
From the first example, we know that passing an object to a function and modifying it directly will change the original. But in the immer example, changing
draft.donedoes not affectstate.done.
With experience from studying Vue’s source code, it’s clear that
Object.definePropertyor similar interception is used here.
How immer works
Looking at the source code, immer does use defineProperty, but in another core file, it uses ES6’s Proxy object. Proxy allows you to intercept and customize operations on objects.
Proxy takes two arguments: the target object and a handler object with traps like get and set.
const proxy = new Proxy({}, {
get(target, key) {
console.log('proxy get key', key)
},
set(target, key, value) {
console.log('value', value)
}
})
proxy.info // 'proxy get key info'
proxy.info = 1 // 'value 1'
In immer, a state is maintained internally, and all operations are intercepted to determine if changes have occurred.
Here’s a simplified Store class:
class Store {
constructor(state) {
this.modified = false
this.source = state
this.copy = null
}
get(key) {
if (!this.modified) return this.source[key]
return this.copy[key]
}
set(key, value) {
if (!this.modified) this.modifing()
return this.copy[key] = value
}
modifing() {
if (this.modified) return
this.modified = true
this.copy = Array.isArray(this.source)
? this.source.slice()
: { ...this.source }
}
}
The Store instance has modified, source, and copy properties, and get, set, and modifing methods. The key is the modifing function, which creates a copy when a change is detected.
The Proxy handler simply forwards get and set operations to the Store instance:
const PROXY_FLAG = '@@SYMBOL_PROXY_FLAG'
const handler = {
get(target, key) {
if (key === PROXY_FLAG) return target
return target.get(key)
},
set(target, key, value) {
return target.set(key, value)
},
}
The getter flag makes it easier to retrieve the store instance from the proxy.
Finally, the produce function creates a Store and a Proxy, passes the proxy to the producer function, and returns the new state:
function produce(state, producer) {
const store = new Store(state)
const proxy = new Proxy(store, handler)
producer(proxy)
const newState = proxy[PROXY_FLAG]
if (newState.modified) return newState.copy
return newState.source
}
This is a minimal version of immer. The real immer has more features, such as structural sharing for deeply nested objects.
Performance
According to the official immer README, here is a simple performance test. The test uses 100,000 components and updates 10,000 of them. “freeze” means the state tree is frozen after creation, which is a best practice to prevent accidental mutations.

Observations:
-
immer’s performance is worse than other frameworks in this test because it proxies a large root node.
-
The mutate baseline shows the cost of direct mutation (no immutability).
-
Proxy-based immer is about twice as slow as a handwritten reducer, but this is negligible in practice.
-
immer is about as fast as immutable-js. However, immutable-js often requires expensive
toJSconversions. -
immer’s ES5 implementation is slower, but for most reducers, this doesn’t matter.
-
Only mutate, deepclone, and native reducers recursively freeze the entire state tree; others freeze only modified parts.
Conclusion
The core principle of immer is intercepting object reads and writes, similar to Vue and mobx. Many JS concepts are interconnected. Theoretically, there are as many ways to make (a == 1 && a == 2 && a == 3) true as there are ways to implement MVVM or immutability. Every small knowledge point can influence the future of frontend development.
메타데이터
- post_id
- 46c2dadff591
- slug
- introduction-and-source-code-analysis-of-immer-js-simpler-and-faster-immutable-data-structures-46c2dadff591
- url
- https://medium.com/@revan_zhang/introduction-and-source-code-analysis-of-immer-js-simpler-and-faster-immutable-data-structures-46c2dadff591
- canonical_url
- https://medium.com/@revan_zhang/introduction-and-source-code-analysis-of-immer-js-simpler-and-faster-immutable-data-structures-46c2dadff591
- author_url
- https://medium.com/@revan_zhang
- status
- ok
- fetched_at
- 2026-09-04 00:13:15