mrkeyoor.com_
Thu 06 Aug 02:41 UTC
npmUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5The core collection API has barely moved since v3 and v4 is still patched alongside v5. The deductions are for the v5 default-export removal and the Map typing rewrite, which made a no-runtime-change release into a substantial TypeScript upgrade
Docs5/5immutable-js.com was rebuilt from generated type-definition dumps into written docs with a live playground on nearly every method, and the README explains the design tradeoffs rather than only listing methods
Maintenance4/5Pushed within days, releases through mid-2026 including backported CVE fixes to the v4 and v3 lines, and a 6.x branch in progress. It is effectively one very active maintainer, which is a real bus-factor risk, and around 100 open issues plus roughly 31 open PRs
Ecosystem4/543M weekly downloads and 33k stars with integrations like redux-immutable and chai-immutable, but most of the surrounding tooling dates from the Flux era and new React code overwhelmingly reaches for Immer instead

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
Skip it if

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'); // 50

Setting 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; // 6

Only 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); // 1006008

Seq 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 compare

This 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 v5

In 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

PackageRegistryPick it when
immernpmYou want immutable updates on plain objects and arrays with normal mutation syntax and no conversion boundaries
mutativenpmSame draft-based model as Immer but faster, for hot paths where the proxy overhead shows up
@reduxjs/toolkitnpmThe immutability problem you actually have is Redux reducers, which this solves with Immer built in