immutable
Immutable.js gives JavaScript a set of persistent data structures: List, Stack, Map, OrderedMap, Set, OrderedSet, Record and a lazy Seq. Persistent means a collection never changes in place. Calling map.set('b', 50) returns a new Map and leaves the original untouched, and the two share almost all of their internal memory through hash array mapped tries and vector tries, the same structures Clojure uses. That sharing is the point: copying a 10,000-entry Map costs a pointer, not 10,000 entries. Two extra behaviors follow from it. Collections compare by value, so Map({a:1}).equals(Map({a:1})) is true while === is false, and any operation that would produce an identical collection returns the original object, so === is a cheap and reliable has-this-changed check for memoization.
Still the right answer when you genuinely need persistent collections with value equality and structural sharing, which is a narrower set of apps than the download count suggests. For ordinary application state, Immer gives you immutability without a parallel type system and without toJS() at every boundary.
Use it if
- You hold large collections in memory and copy them often (undo stacks, editor document state, time-travel debugging), where structural sharing turns an O(n) clone into an O(log n) update
- You need value equality on collections: .equals() and Immutable.is() compare contents deeply, and hashCode() lets you use a whole collection as a Map key or Set member
- You want cheap change detection in React or a memoized selector, because an update that changes nothing returns the identical reference and === short-circuits the work
- You want a lazy Seq to chain map, filter and take over a large or infinite source without materializing intermediate arrays
- Your state is small and you just want copy-on-write updates of plain objects. Immer does that with normal JavaScript syntax and no new types, and structuredClone plus spread covers most of the rest
- The data crosses boundaries constantly. Every fetch response has to go through fromJS() and every render, serialization or third-party call has to go back through toJS(), which is a full deep copy and loses all the sharing you were paying for. Teams that end up with toJS() sprinkled through components have a slower app than they started with
- You are budgeting bundle size. The full library is around 18 KB gzipped, and it ships as one prebuilt bundle with no exports map and no sideEffects flag, so importing only Map does not reliably tree-shake the rest away
- You want the whole team to read the code without training. Records, Seq laziness, the difference between merge and mergeDeep, and the rule that only set, push and pop work inside withMutations are all things people get wrong for months
- You are on TypeScript and want inference to just work. Records need an explicit factory type, getIn and setIn on deep structures degrade to weak types, and v5 rewrote the Map typings hard enough that upgrading from v4 is a real TypeScript migration even though runtime behavior is unchanged
Setup reality
npm install immutable and that is the install: no dependencies, no peers, no build step, TypeScript and Flow definitions bundled. TypeScript users need es2015 in lib or target, because the definitions use ES2015 iterators. The work is in the code, not the install. v5 removed the default export, so import Immutable from 'immutable' no longer works and you need named imports or import * as Immutable. v5 also removed Map.of and the Collection.isKeyed style statics, requires Range() to be called with at least a start and an end, and changed the hashCode of OrderedMap and OrderedSet. The largest v5 change is TypeScript-only: Map is now typed close to a plain object, which is better but will surface a pile of new errors in a v4 codebase. Expect to write conversion boundaries by hand, and expect Redux DevTools and most inspectors to show you opaque internals unless you install the browser extension the docs point at.
Patterns
Create a Map and update it without mutatingcreate-and-update
import { Map } from 'immutable';
const map1 = Map({ a: 1, b: 2, c: 3 });
const map2 = map1.set('b', 50);
map1.get('b'); // 2
map2.get('b'); // 50Setting a key to the value it already holds returns the original object, so map1.set('b', 2) === map1 is true.
Import the way v5 requiresnamed-imports-v5
// v4 and earlier
// import Immutable from 'immutable';
// v5: the default export is gone
import { List, Map, Set, Record } from 'immutable';
// or, if you really want the namespace:
import * as Immutable from 'immutable';A v4 codebase upgrading to v5 fails at import time, not at runtime, which makes this the first thing to fix.
Compare collections by valuevalue-equality
import { Map, is } from 'immutable';
const a = Map({ x: 1, y: 2 });
const b = Map({ x: 1, y: 2 });
a === b; // false
a.equals(b); // true
is(a, b); // true
// collections work as keys because they hash by value
import { Set } from 'immutable';
Set([a, b]).size; // 1=== only tells you nothing changed; it never tells you two separately built collections differ, so use equals() for correctness and === as a fast path.
Update deeply nested datanested-updates
import { fromJS } from 'immutable';
const state = fromJS({
users: { u1: { name: 'Ada', tags: ['admin'] } },
});
const next = state
.setIn(['users', 'u1', 'name'], 'Ada L')
.updateIn(['users', 'u1', 'tags'], (tags) => tags.push('owner'));
next.getIn(['users', 'u1', 'tags']).toJS(); // ['admin', 'owner']fromJS converts objects to Map and arrays to List all the way down, so tags is a List and needs push, not concat on an array.
Batch many writes with withMutationsbatch-mutations
import { List } from 'immutable';
const list1 = List([1, 2, 3]);
const list2 = list1.withMutations((list) => {
list.push(4).push(5).push(6);
});
list1.size; // 3
list2.size; // 6Only set, push and pop mutate the transient copy; map, filter, sort and splice still allocate new collections inside the block, which quietly removes the benefit.
Define a fixed shape with Recordrecords-typed-shape
import { Record } from 'immutable';
const Settings = Record({ theme: 'light', fontSize: 14 });
const s1 = new Settings();
const s2 = s1.set('theme', 'dark');
s2.theme; // 'dark' (property access, not .get)
s1.theme; // 'light'
s2.toJS(); // { theme: 'dark', fontSize: 14 }Setting a key that is not in the Record definition throws, which is the point; in TypeScript declare the factory type explicitly or inference gives you a very wide type.
Know which merge you wantmerge-vs-mergedeep
import { fromJS } from 'immutable';
const base = fromJS({ user: { name: 'Ada', prefs: { theme: 'light' } } });
const patch = { user: { prefs: { fontSize: 14 } } };
base.merge(patch).getIn(['user', 'prefs']).toJS();
// { fontSize: 14 } <- name and theme are gone
base.mergeDeep(patch).getIn(['user', 'prefs']).toJS();
// { theme: 'light', fontSize: 14 }merge replaces nested values wholesale. This is the single most common source of silently lost state in Immutable.js code.
Chain operations without intermediate collectionslazy-seq
import { Seq, Range } from 'immutable';
const oddSquares = Seq([1, 2, 3, 4, 5, 6, 7, 8])
.filter((x) => x % 2 !== 0)
.map((x) => x * x);
// nothing has run yet
oddSquares.get(1); // 9, filter ran 3 times and map ran once
Range(1, Infinity)
.skip(1000)
.map((n) => -n)
.filter((n) => n % 2 === 0)
.take(2)
.reduce((r, n) => r * n, 1); // 1006008Seq caches nothing, so iterating the same Seq twice does the work twice; call toList() when you will reuse the result.
Convert at the edges, not everywhereconvert-boundaries
import { fromJS } from 'immutable';
// once, at the boundary
const state = fromJS(await res.json());
// cheap: pull out only the leaf the view needs
const name = state.getIn(['user', 'name']);
// expensive: full deep copy of the whole tree
const plain = state.toJS();toJS() is a deep copy that returns a brand new object every call, so calling it inside a React render or a memoized selector defeats the memoization it was supposed to help.
Use reference identity for render skippingreact-memo-check
import { memo } from 'react';
const Row = memo(function Row({ item }) {
return <li>{item.get('label')}</li>;
});
// parent
const next = items.update(3, (item) => item.set('label', 'renamed'));
// every entry except index 3 keeps the same reference,
// so only one Row re-renders under the default shallow compareThis works only if you pass the Immutable collection straight through; calling toJS() before passing it creates fresh objects and every row re-renders.
Split a collectiongroup-and-partition
import { List } from 'immutable';
const nums = List([1, 2, 3, 4, 5, 6]);
const [odds, evens] = nums.partition((n) => n % 2 === 0);
// odds: List [1, 3, 5], evens: List [2, 4, 6]
const byMod = nums.groupBy((n) => n % 3);
// Map { 1: List [1, 4], 2: List [2, 5], 0: List [3, 6] }partition returns the rejected items first and the kept items second, which is the opposite of what most people guess; both it and groupBy are eager, unlike filter.
Call Range with both boundsrange-two-args
import { Range } from 'immutable';
Range(0, 10); // ok
Range(0, Infinity); // ok, explicit infinite range
// Range() and Range(0) throw since v5In v4 an undefined bound produced an infinite loop; v5 turned that into an error, so old helper code that built ranges dynamically needs a default.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| immer | npm | You want immutable updates on plain objects and arrays with normal mutation syntax and no conversion boundaries |
| mutative | npm | Same draft-based model as Immer but faster, for hot paths where the proxy overhead shows up |
| @reduxjs/toolkit | npm | The immutability problem you actually have is Redux reducers, which this solves with Immer built in |