Mutative's copy-once drafts and the eleven cases Immer wins
Efficient immutable updates, about 3-6x faster than Immer.
At a glance
- What is it?
- Mutative is a TypeScript library for immutable updates that copies each object once per update and leaves auto-freeze off by default, the two choices behind its lead over Immer in the project's own benchmark. The same tables also show where Immer stays ahead and where a plain spread reducer beats both.
- Who is it for?
- Adopt Mutative when your state tree is large, interactive and never serialized: the copy-once rule and the default-off freezing are exactly what the benchmark rows reward. Walk away when a reducer written by hand already fits on one screen, since the same table beats the library by an order of magnitude on most narrow shapes.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 1 day ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on November 7, 2026, and from our analysis. They are not legal advice.
Star history
- unadlib/mutative2,027 ★ · +29
| Repository | 2026-10-15 | 2026-11-07 |
|---|---|---|
| unadlib/mutative | 2k | 2k |
29 Stars in the last 23 days, measured from the daily GitHub snapshot. Compare with other projects
Editorial analysis
Copy-once drafts: why a recipe touches each object once
Mutative is a TypeScript library that turns a recipe function into a new immutable value. A recipe looks like ordinary mutation on a draft, but the design rule underneath is narrow and checkable: each object is copied at most once per update, no matter how many changes the recipe makes. Everything else in the design serves that rule, from the shallow copy optimization to lazily created drafts and a cheaper finalization pass.
That is the entire disagreement with the Immer model it borrows. Immer has to keep drafts consistent because the result may be frozen all the way down before anyone reads it, and freezing is on by default there. Mutative drops the obligation, so no freeze walk runs over the result or over any object handed along the way. What remains is bookkeeping: drafts are still tracked, and a recipe still cannot reach outside its own callback to touch the original.
The benchmark harness: 93 workloads against Immer 11.1.18
The benchmark suite times 93 workloads: Immer's own performance tests, array methods, reads, Map and Set values, object records, class instances, a deep path, patch application, returned values and searches. It compares Mutative against Immer 11.1.18 and against reducers written by hand, and every result is checked against those hand-written reducers before the timing counts.
Times are microseconds per update, medians of three runs on an Apple M1 Max with Node.js 24.16.0, and lower is better. Record rows and the 1,000-property row each ran in a process of their own, because code that ran earlier in a process changes how fast V8 copies an object that wide. Running the whole table takes one script:
pnpm build && pnpm benchmark:immer:build && node perf-testing/run-benchmarks.mjsA verification pass that compares outputs instead of only timings:
node --expose-gc --enable-source-maps perf-testing/dist/immutability-benchmarks.mjs --check --patches bothThe complete tables sit in perf-testing/reports/SUMMARY.md, and perf-testing/README.md describes the options the harness accepts.
The eleven matched cases where Immer stayed ahead
Immer was faster in 11 of the 530 matched cases, and the three shapes involved are named rather than buried. First, returning a new state built from frozen data, where Immer does not search for drafts at all: on that row Mutative takes 2,914 microseconds against 8,272 for Immer without freezing and 0.89 for Immer running with its default. Second, applying patches that replace nested values, where Immer leads by 5-7%, visible at 1,558 against 1,465 without freezing and 2,440 with it. Third, a class instance with 1,000 fields while auto-freeze is on, and no row for that exact size is published.
Read together, those three describe where copying is the wrong trade. A result assembled from data that is already frozen does not need draft discovery, and a value swapped in wholesale does not need a path walked through it. Neither of those two shapes is exotic, and both land on code that receives pre-frozen payloads from remote data transfer.
Hand-written spread reducers beat most rows of the table
The hand-written column is the harder comparison, and Mutative loses it more often than it wins. Updating one field of a small object takes 0.65 microseconds against 0.05 for a spread reducer. Reading every row by index at 10,000 rows costs 5,210 against 135, updating every one of those rows costs 7,008 against 243, and adding a number to a Set costs 1,013 against 72.4. Returning a filtered draft costs 2,444 against 80.2, and 200 RTK Query-style updates cost 1,031 against 361.
Two rows go the other way. Inserting into a 1,000-property object costs 53.0 against 131, and updating one of 10,000 records costs 1,242 against 2,177. Both are copy-dominated cases, which is the honest boundary of the library: it earns its place on wide objects and shallow shapes, and loses to four lines of reducer on most narrow ones. The README links two write-ups on why reduce with a spread is expensive, and the table is the argument against taking that advice too far.
What ships in the npm package and how exports resolve it
There is no install command in the README, so the package page behind the npm badge and the docs site are where the library is picked up; the version in package.json is 1.3.0. That file does settle the module layout, which is worth reading before a bundler complains:
"main": "dist/index.js",
"module": "dist/mutative.esm.js",
"react-native": "dist/mutative.esm.js",
"source": "src/index.ts",
"types": "dist/index.d.ts"Modern resolvers go through the exports map instead, which splits type declarations per condition:
".": {
"import": {
"types": "./dist/index.d.mts",
"node": "./dist/index.mjs",
"default": "./dist/mutative.esm.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}The files field ships dist and src, so the TypeScript sources travel beside the build output. For a first run, rawReturn() is the one function the README names, and Immer has no equivalent of it: on the Return it with rawReturn() row Mutative spends 0.30 microseconds, against 2,914 on the Return a new state row right above it.
The root tooling: vitest, tsc, oxlint and a package check
The root carries pnpm-lock.yaml and pnpm-workspace.yaml, and the npm scripts call pnpm rather than npm. Four of those scripts define the loop a contributor runs:
"test": "vitest run",
"type-check": "tsc --noEmit && tsc --noEmit -p test",
"lint": "oxlint",
"test:package": "node scripts/check-package.mjs"Type checking runs twice, once for the library and once for the test folder, so the type assertions there are verified apart from the runtime suite. Linting is oxlint, configured through .oxlintrc.json with .oxfmtrc.json beside it for formatting. Tests run under vitest.config.mts, the bundle comes out of tsdown.config.mjs, and the coverage script pipes lcov output into coveralls. The MIT licence covers what ships, and the last push was on 2026-10-06, so the 1.3.0 in package.json is the current published version rather than a moving target. Nothing in the README describes what check-package.mjs asserts, so whether it exercises the exports map above is not something the repository states.
Two workload counts in one README: 90 and 93
Two workload counts sit a few paragraphs apart in the same README. The opening says the comparison covers 90 workloads. The Performance section says the suite times 93 workloads, and reports 530 measured cases under matched settings plus 136 under each library's defaults. Neither count is reconciled, and the figures under them, 508 of 530 at 3.1x on geometric mean and 133 of 136 at 6.0x, are the ones worth quoting.
The move numbers drift in the same direction. The opening claims elements moved up to 1,125x faster than Immer, while the large-array table reports 212x for moves at 1,000 rows and 472x at 10,000 rows, both measured with auto-freeze and patches off. That table is also cut off partway: its last visible row carries the auto-freeze value on and leaves the columns after it empty. The headline and the tables are not quoting the same run.
Where freezing is unavoidable, skipping auto-freeze buys nothing
The pitch of the library is that you do not freeze what you build, so the cases where freezing is mandatory are the ones where the saving disappears. The motivation section names two: cross-processing and remote data transfer, where immutable data has to be constantly frozen. In a pipeline like that the copy still happens and the freeze still happens, while Immer's auto-freeze default would have handled the second step inside the same pass.
The class instance with 1,000 fields argues the same point from the other side, since with auto-freeze on that is a case Immer wins. So is the frozen-state return. Mutative is built for long-lived interactive trees where the identity of untouched branches is what makes re-rendering cheap, and for code that needs to hold an array in place without copying it into a fresh variable every time.
Editorial conclusion
Adopt Mutative when your state tree is large, interactive and never serialized: the copy-once rule and the default-off freezing are exactly what the benchmark rows reward. Walk away when a reducer written by hand already fits on one screen, since the same table beats the library by an order of magnitude on most narrow shapes. Before porting a reducer, read two blocks in perf-testing/reports/SUMMARY.md: the 11 matched cases Immer wins, and the hand-written column for the shape you actually update.
Frequently asked questions
How do I install Mutative in a JavaScript project?
The README carries a badge pointing at the npm package page instead of an install line, and the version published in package.json is 1.3.0. The package ships dist and src, with dist/index.js for require and dist/index.mjs for Node imports.
Is Mutative actually faster than Immer?
In the project's own suite, Mutative was faster in 508 of 530 matched cases at 3.1x on geometric mean, and in 133 of 136 cases at 6.0x when each library used its own defaults. Immer won 11 matched cases, which the README names one by one.
Does Mutative freeze the state it returns?
The comparison attributes much of Immer's cost to auto-freeze being on by default there, and every Mutative column in the benchmark runs without it. The motivation section states that frozen state is unavoidable in cross-processing and remote data transfer.
What does rawReturn() do and when should I reach for it?
It returns a value the recipe built rather than a draft, and Immer has no equivalent. On the benchmark row that uses it Mutative spends 0.30 microseconds, against 2,914 for returning a new state and 0.06 for a hand-written reducer.
Can Mutative replace Immer in an existing codebase?
The README states that Mutative passed all of Immer's test cases, and the repository keeps test/immer-non-support.test.ts for the edge cases Immer does not handle. The benchmark itself pins its comparison to Immer 11.1.18.
Official sources
Where developers are discussing it
Posts on Hacker News, dev.to and Lobsters that link to this repository, found by our scan of those communities. 1 in the last 30 days; newest first.
- Announcing Mutative 2.0 - Faster Updates and Safer Patchesdev.to · Oct 13, 2026 · 4 points · 0 comments
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/unadlib-mutative)Star history chart for your README
[](https://hysenlabs.com/projects/unadlib-mutative)Was this useful?