← Blog

Announcing Mutative 2.0: Faster Updates and Safer Patches

· 24 min read

Mutative lets you write immutable updates as ordinary mutations. A recipe changes a draft of the state, and Mutative returns the next state, copying only what the recipe changed and sharing the rest with the previous state.

Mutative 2.0 keeps the API of 1.x and changes the work behind it. Picture a worker that takes the first job off a queue of 10,000 jobs and puts it back at the end when it fails. In 1.x, that shift() call moved each of the 9,999 remaining jobs through the draft's proxy. In 2.0, it moves them natively on the draft's copy: with auto-freeze and patches off, removing the first of 10,000 jobs took 7 µs, against 8.2 ms with 1.3.0, more than 1,000 times as long.

Most workloads gain far less. Across the 97 scenarios of the benchmark, 2.0 was 3.6 times as fast as Immer 11.1.18 with matched settings and 4.3 times as fast as 1.3.0, on geometric mean, and slower than Immer in 6 of 614 measured cases.

The changes fall into three areas:

  • Less work on arrays, collections and snapshots. Array methods that move elements run natively, and Set and Map drafts, current() and apply() do less work per update.
  • Correct results in edge cases. Patches follow Immer's order and replay where some 1.x patches failed, Set drafts keep every change, and apply() is stricter about the paths it accepts.
  • Predictable builds and types. Bundlers and Node.js both select the development or production build from process.env.NODE_ENV, and more types match what the code returns.

No function or option was renamed or removed, and no new option is required. This is a major release because observable behavior changes: patch order and values, freezing of Map and Set instances, build selection, production errors and some inferred types. Builds now target ES2018, and several bug fixes change results that 1.x computed incorrectly.

Upgrading from 1.x? The checklist at the end lists what to check, the migration guide describes every change since 1.3.0, and the release notes list the pull requests and contributors of 2.0.

How Mutative works

If Mutative is new to you, this example shows the model. The recipe marks the first job as done. With enablePatches, create() also returns patches that describe the change and inverse patches that undo it, which apply() can replay:

import { apply, create } from 'mutative';

const baseState = {
  settings: { maxAttempts: 3 },
  queue: [
    { id: 1, done: false },
    { id: 2, done: false },
  ],
};

const [nextState, patches, inversePatches] = create(
  baseState,
  (draft) => {
    draft.queue[0].done = true;
  },
  { enablePatches: true }
);

// The base state is unchanged, and what the recipe did not change is shared.
expect(baseState.queue[0].done).toBe(false);
expect(nextState.queue[0].done).toBe(true);
expect(nextState.settings).toBe(baseState.settings);
expect(nextState.queue[1]).toBe(baseState.queue[1]);

// Patches describe the change, and inverse patches undo it.
expect(patches).toEqual([
  { op: 'replace', path: ['queue', 0, 'done'], value: true },
]);
expect(apply(baseState, patches)).toEqual(nextState);
expect(apply(nextState, inversePatches)).toEqual(baseState);

A draft is a Proxy. The next state holds a shallow copy of each object that the recipe changed and of the objects that lead to it, made at most once per update, and shares every other object with the base state. Reference checks, such as React's memoization or a store's selectors, can then skip what did not change.

Drafts are revoked when the recipe ends, so keep the next state or a current() snapshot, not a draft. The next state is frozen only with enableAutoFreeze: true. Concepts explains the model, and Pitfalls collects its rules.

Less work on arrays, collections and snapshots

Array methods that move elements

In 1.x, array methods ran through the draft's proxy, so shift() read and wrote every remaining element through it. In 2.0, shift(), unshift(), splice() and reverse() move the elements of a plain array directly on the draft's copy. indexOf(), lastIndexOf() and includes() search the current array natively, and sort() and join() run natively on arrays of primitives.

An object of the base state that these methods remove comes back as a draft, so changing it leaves the base state alone. An element that only moves keeps its identity, and the patches replay in both directions. Here is the retry from the introduction, on a short queue:

import { apply, create, isDraft } from 'mutative';

const baseState = {
  queue: Array.from({ length: 5 }, (_, id) => ({ id, attempts: 0 })),
};

const [nextState, patches, inversePatches] = create(
  baseState,
  (draft) => {
    // shift() moves the other jobs natively on the draft's copy and returns
    // the removed job as a draft.
    const job = draft.queue.shift()!;
    expect(isDraft(job)).toBe(true);
    job.attempts += 1;
    draft.queue.push(job);
  },
  { enablePatches: true }
);

expect(nextState.queue.map((job) => job.id)).toEqual([1, 2, 3, 4, 0]);
expect(nextState.queue[4].attempts).toBe(1);
// The base state is unchanged, and the jobs that only moved are shared.
expect(baseState.queue[0].attempts).toBe(0);
expect(nextState.queue[0]).toBe(baseState.queue[1]);
// The patches replay in both directions.
expect(apply(baseState, patches)).toEqual(nextState);
expect(apply(nextState, inversePatches)).toEqual(baseState);

These native paths are built for dense arrays of plain data. Sparse arrays, array subclasses, arrays under a custom mark and methods with callbacks take the proxy path, as do calls in strict mode on arrays that may hold objects, unless they run inside unsafe(). The native methods read getters on array indices as plain values, so a getter may run a different number of times than through the proxy. The FAQ and the native array method contract give the details.

Search a snapshot, change the draft

Methods with callbacks, such as find() and filter(), still run through the draft, so a read-only scan may create a draft for every row it visits. For a read-only search of a large array, search a current() snapshot of plain values instead, and change the match through the draft. In 2.0, current() copies a changed array from its current elements rather than reading each one through the draft, and the snapshot keeps the draft's indices after elements move:

import { create, current } from 'mutative';

const baseState = {
  queue: Array.from({ length: 10_000 }, (_, id) => ({ id, paused: false })),
};

const nextState = create(baseState, (draft) => {
  draft.queue.shift();
  // Search a snapshot of plain values, which creates no drafts, and change
  // the match through the draft.
  const index = current(draft.queue).findIndex((job) => job.id === 9_999);
  draft.queue[index].paused = true;
});

expect(nextState.queue).toHaveLength(9_999);
expect(nextState.queue[9_998]).toEqual({ id: 9_999, paused: true });
expect(nextState.queue[0]).toBe(baseState.queue[1]);

With auto-freeze and patches off, the recipe above took 257 µs, against 15,717 µs with 1.3.0 and 11,255 µs with Immer. When the recipe updates the first row instead of removing it, it took 176 µs with the snapshot search and 2,169 µs with a search through the draft.

Sets, Maps and applying patches

Set and Map updates also do less work. As soon as a recipe read a Set, 1.3.0 mapped every item, and it copied the Set when a lookup missed. 2.0 does neither: like Immer, it answers lookups such as has() and size from the original Set until the recipe changes it. Even then, it edits the Set's copy directly and tracks only the items that have drafts, instead of copying every item into a Map and back. A Map draft's forEach() reads entries from the draft's state rather than through the proxy.

apply() gains from the native array methods too, since it inserts and removes array elements with splice() on a draft. It also reads the type of each draft on a patch path once per step, where it used to ask the proxy twice.

What the benchmark shows

Unless noted, the numbers in this post come from one benchmark batch, run on October 12, 2026, that compared Mutative 2.0 with Mutative 1.3.0 and Immer 11.1.18 in 97 scenarios, with auto-freeze and patches each off and on. A cell is one scenario at one size in one mode; How the numbers were measured gives the rest of the setup.

With matched settings, 2.0 was faster than Immer in 595 of 614 cells, within 5% in 13 and slower in 6, with a geometric mean speedup of 3.58. Against 1.3.0, it was faster in 535 cells, within 5% in 70 and slower in 9, with a geometric mean of 4.27. With each library's defaults, where Immer freezes the next state, Mutative does not, and neither generates patches, 2.0 was faster than Immer in 154 of 157 cells, with a geometric mean of 6.63. These means weight every cell equally, so they describe this suite, not the speed of your application.

The table shows where the gains come from and where they stop. Times are microseconds per scenario, with auto-freeze and patches off unless the row says otherwise; lower is better.

WorkloadMutative 2.0Mutative 1.3.0Immer 11.1.18
Remove the first of 10,000 rows with shift()7.048,2037,408
Look up two numbers in a Set of 10,000 and read its size0.625440.66
Update one object in a Set of 10,0005311,0246,024
Push a row onto 10,000 rows and insert a property, with patches63.2210360
Insert, remove and replace a row of 100 with apply()2.9711599.0
200 RTK Query-style updates1,0011,8491,133
Update one of 10,000 records1,2001,1882,094
Update a field of a class instance with 1,000 fields, with auto-freeze402411237

The RTK Query-style row simulates a reducer for 100 pending and 100 resolved requests, without running Redux Toolkit; in that mix of small updates, 2.0 took about half as long as 1.3.0 and 12% less time than Immer. Updates that spend their time copying one large object, record or Map, like the records row, take about as long as before. And patches add little when an update changes only a few paths: the push-and-insert update took about 63 µs with or without patches, while 1.3.0 went from 65.8 µs to 210 µs and Immer from 161 µs to 360 µs.

Where 2.0 is slower

Against Immer with matched settings, 2.0 was slower in 6 cells, all with auto-freeze. Four update one field of a class instance with many fields: with 1,000 fields, 2.0 took 1.7 times as long, and with 100 or 10,000 fields and without patches, about 5% longer. The summary attributes the gap to freezing the copy, which 2.0 builds property by property from descriptors where Immer 11 uses Object.assign(). Without auto-freeze, the update of 1,000 fields took 2.0 either about 98 µs or about 200 µs, depending on the process, against 203 µs for Immer. In the other two cells, which push a row onto 100 rows and prepend rows with concat(), the difference was smaller than the variation between the runs of each library. With each library's defaults, all 3 slower cells return a new state of rows frozen earlier, a case that Auto-freeze and returned values explains.

Against 1.3.0, 2.0 was 8–18% slower in 9 cells, all with auto-freeze, and in all but one of them the difference was smaller than the variation between runs. A hand-written reducer is still faster in most cases: in the 150 cells where the suite's spread-based reference reducer ran, 2.0 was slower in 129 and took 8.4 times as long on geometric mean.

Two other costs are worth knowing. With patches, a Set that adds or deletes items compares all of its items to emit its patches. With auto-freeze off, adding a number to a Set of 10,000 numbers took 440 µs with patches and 29.5 µs without. Browser engines also differ from Node.js: in WebKit 26.4, Object.freeze() of a 10,000-element array took about 2.4 ms and every scan of a frozen array was slow, so an update of 10,000 frozen rows took several milliseconds with every Mutative build measured there. Tradeoffs and limits lists the rest, including memory.

Bundle size

Some of this speed is paid for in bytes. Patches, Map and Set support and the native array methods are built into create() rather than offered as plugins, so a bundler cannot drop them. Bundled for the browser in production mode, minified and compressed with Brotli, create() alone takes 7.9 kB, 2.1 kB more than with 1.3.0, against 3.6 kB for produce() from Immer 11.1.21, the latest release when the sizes were measured. create(), apply(), current() and original() together take 8.5 kB, 2.2 kB more than with 1.3.0, against 6.4 kB for their Immer counterparts with its patch, Map/Set and array-method plugins enabled. The README records the setup; 1.3.0 was measured the same way.

How the numbers were measured

The batch measured main at commit 40f3a42, before its version changed from 1.3.0 to 2.0.0. The published 2.0.0 package contains the same production file, with the SHA-256 that the summary records for the measured build, cb6464f3…. The 97 scenarios, which the benchmark guide describes, ran at 100 rows, and 30 of them also at 1,000 and 10,000 rows, on an Apple M1 Max with Node.js 24.16.0, against Mutative 1.3.0 and Immer 11.1.18 from npm. Each library ran each scenario in three processes of its own, and each time is the median of the three process means. With auto-freeze on, the inputs were frozen in advance, so the first freeze of outside data is not timed, and patch measurements use arrayLengthAssignment: false in Mutative to match Immer's index-removal patches.

Immer's opt-in enableArrayMethods() plugin was off. In 11.1.18, as the audit shows, and still in 11.1.21, it returns objects of the base state from shift(), pop() and splice(), so editing a removed object, as the retry recipe above does, changes the previous state, and its forward patches can fail to replay. Measured separately with the plugin, Immer was faster in 59 of 328 cells, mostly where it hands base objects to callbacks, which 2.0 never does, and slower in every cell that moves elements.

The performance summary records the results, method and limits of the batch. The Performance page and the latest summary show current results, and pnpm benchmark:immer runs the suite in a clone of the repository.

Correct results in edge cases

Alongside the speed work, 2.0 fixes cases in which 1.x produced a wrong state or patches that did not replay. These are the fixes most likely to matter; the migration guide lists them all.

Patches

If you record patches for undo, redo or synchronization, expect these differences:

  • Order. Patches of a nested draft come before those of its parents, as in Immer. Applying them gives the same state as before, and the replace patches that only restated an unchanged draft at its original index are gone.
  • Values. A patch value that was a draft is now the object that the next state holds, not a deep copy, so auto-freeze freezes it too. Copy a patch value before you change it.
  • Replay. Patches now replay correctly where those of 1.x could fail or produce a wrong state: after sort() or an assignment moved or replaced a changed draft, when a Set added or removed items and also changed one, when a recipe assigned a draft's original object to the key that holds the draft, and when an array changed between 0 and -0 or shrank and grew again. The migration guide describes each case.

Some fixes change the next state itself. With patches on, when two or more items of a Set below the root changed below their first level, as worker.stats.jobs does here, 1.3.0 kept the change of only one of them. 2.0 keeps every change, and the patches replay in both directions:

import { apply, create } from 'mutative';

const baseState = {
  workers: new Set([
    { name: 'build-1', stats: { jobs: 1 } },
    { name: 'build-2', stats: { jobs: 2 } },
  ]),
};

const [nextState, patches, inversePatches] = create(
  baseState,
  (draft) => {
    for (const worker of draft.workers) {
      worker.stats.jobs += 1;
    }
  },
  { enablePatches: true }
);

expect([...nextState.workers].map((worker) => worker.stats.jobs)).toEqual([
  2, 3,
]);
expect([...baseState.workers].map((worker) => worker.stats.jobs)).toEqual([
  1, 2,
]);
expect(apply(baseState, patches)).toEqual(nextState);
expect(apply(nextState, inversePatches)).toEqual(baseState);

Some limits remain. When patches add objects to a Set, apply() copies them, so inverse patches cannot remove the copies by their original identity, and added items go to the end, which can shift the positions that later patches address. For repeated undo and redo or synchronization of such collections, use a Map keyed by an id, or an array; see Sets of objects. Applying the patches of a sparse array can also turn its holes into explicit undefined entries.

Applying patches

apply() is also stricter. It now rejects a path that ends in __proto__, as it already rejected __proto__ earlier in a path, and reads a Set item only by its index, so a path can no longer reach Array.prototype through a Set. If you apply patches from sources that you do not control, such as other clients, these checks are a reason to upgrade. Map keys in a path are used as they are, without conversion to strings. Copies of patch values keep their own symbol keys, and an own __proto__ key, such as one from JSON.parse(), stays a plain property. With mutable: true, a patch that replaces the root state now throws, since the state cannot be replaced in place. Patches that apply() accepts lists the rules.

Collections, iterators and failed producers

Set and Map drafts now behave more like the built-ins they stand for. A draft of a Set subclass changes an instance of the subclass, so the subclass's own add(), has(), delete() and clear() take effect during the recipe; its add() must store the value it receives. A Set draft that the recipe moves elsewhere, such as to another key, keeps its changes there. And the iterators of Map and Set drafts work like built-in ones: a partly consumed iterator continues where it stopped, and iterator helpers such as toArray() are available where the engine has them.

These iterators now also end with their draft. A draft lives until its synchronous recipe finishes, until the Promise of its async recipe settles, or until a manual draft's finalize() succeeds. After that, Map value and entry iterators and all Set iterators throw a TypeError instead of yielding another value. Exhausted iterators stay exhausted, and Map.keys() keeps its native iterator, because Map keys are not drafted. Consume these iterators while their draft is active.

A producer that fails after its recipe returned, for example because the recipe changed the draft and also returned another value, now revokes its drafts as a throwing recipe does, and keeps the original error. Manual drafts differ: if finalize() throws, the draft can stay writable, so start a new draft from the base state instead of reusing it; see manual drafts.

Auto-freeze and returned values

With enableAutoFreeze: true, 2.0 freezes Map and Set instances themselves, as Immer does; 1.x only replaced their mutating methods. Later producers skip a frozen collection instead of walking it again, but adding a property to a frozen Map or Set, or replacing one of its methods, now fails.

A recipe can also return a new state. That value may hold drafts, as { ...draft, count: 0 } does, so Mutative searches it for them. With auto-freeze, the production build of 2.0 skips frozen objects in the returned value and everything below them, which makes returning an earlier, frozen state cheap. Development builds still search there and throw when they find a draft, so never put a draft inside a frozen object, or inside anything that a frozen object holds. rawReturn(), which 1.x already had, skips the search for a value that holds no drafts; never wrap one that does.

This search explains the 3 cells in which 2.0 at its defaults was slower than Immer at its defaults. Each returns a new state of rows that were frozen earlier. Immer, which freezes by default, skips those rows, while 2.0 without auto-freeze searches all of them, taking 2.60 ms at 10,000 rows where Immer took 0.79 µs. rawReturn() avoids the search, and with auto-freeze, the production build of 2.0 skips the frozen rows, taking 0.53 µs. Fresh data reverses the result: in a separate run on October 13, 2026, with the same production build, returning 10,000 rows that each call creates took 9.2 ms with Immer's default auto-freeze, 3.1 ms with 2.0 at its defaults, and 0.23 ms with rawReturn().

Predictable builds and types

The package contains a development build, with full error messages, warnings and extra checks, and a minified production build. In 2.0, process.env.NODE_ENV selects between them in bundlers and Node.js:

  • Bundlers resolve the ESM build, whose development checks now test process.env.NODE_ENV. When your production build replaces it with 'production', the bundler drops the development code; in 1.x, the ESM build always ran it.
  • Bundles for Node.js, such as esbuild's with platform: 'node', use the CommonJS entry, which selects a build when it loads. If such a bundle does not define process.env.NODE_ENV, it includes both builds, which more than doubles a minified bundle of create(). Define the variable, or keep Mutative external.
  • Node.js resolves import to a module that re-exports the CommonJS build, so import and require share one instance, which reads NODE_ENV once when it loads. In 1.x, import always ran the development ESM build, as a separate instance.

Browsers, and other environments without a process global, fail with process is not defined when they load dist/mutative.esm.mjs directly. Load dist/mutative.esm.production.min.mjs or the UMD build there instead; see Installation.

Production builds throw Minified Mutative error #<code> with a link to the errors page, which decodes the code. CommonJS consumers already got these errors in 1.x; in 2.0, production bundles get them too, and the errors of apply(), original() and rawReturn() are now coded as well. BUILDING.md describes the build pipeline.

More checks in development

Production builds skip the development checks, so run your tests with the development build. In 2.0, it also catches these mistakes:

  • Drafts as Map keys. draft.map.set(draft.key, value) throws, since the next state would keep a revoked draft as the key. Use original(draft), current(draft) or an id instead.
  • Keys that string paths cannot name. With enablePatches: { pathAsArray: false }, a patch path that would hold a non-string Map key or a symbol key throws. Keep the default array paths for such keys.
  • Searches through large drafts. In strict mode, a recipe that leaves 1,000 or more drafts unchanged warns once. A current() snapshot can usually replace the search that caused it.

With auto-freeze, the check for circular references now reports only an object that holds itself, so a changed draft that holds its original, as draft.prev = original(draft) makes it, no longer throws. Cycles are still not supported. The errors page lists the messages.

Output and package entries

Every build now targets ES2018 instead of ES2015, which keeps object spread native. Mutative still needs Proxy, and engines that have Proxy but not ES2018 syntax, such as Chrome 49–59, Firefox 18–54, Safari 10–11.0 and Edge 12–18, need Mutative transpiled by your build. The package still declares Node.js 14.0 as its minimum, and CI runs a short test of the packed package on Node.js 14.0.0.

The exports map now gives import and require their own type declarations, so NodeNext resolution gets declarations of the module kind it imports. The package checks exercise the CommonJS, ESM and UMD builds and both NodeNext and Bundler type resolution. The declarations support TypeScript 5.0 and later.

Types

Several types now match what the code returns. With an explicit state type, an async recipe gives Promise<State>, and so do curried producers of async recipes; 1.x typed the result as State. And apply() accepts enableAutoFreeze: true and returns Immutable<State>, where the types of 1.x rejected the option:

import { apply, create, type Immutable, type Patches } from 'mutative';

type State = { count: number; tags: string[] };

const baseState: State = { count: 0, tags: [] };

// 1.x typed this result as State; 2.0 types it as the Promise that it is.
const pending: Promise<State> = create<State>(baseState, async (draft) => {
  draft.count = await Promise.resolve(1);
});
expect((await pending).count).toBe(1);

// The types of 1.x rejected enableAutoFreeze here; 2.0 types the frozen result.
const patches: Patches = [{ op: 'add', path: ['tags', 0], value: 'v2' }];
const frozen: Immutable<State> = apply(baseState, patches, {
  enableAutoFreeze: true,
});
expect(frozen.tags).toEqual(['v2']);
expect(Object.isFrozen(frozen.tags)).toBe(true);

Curried producers that annotate their draft and return a new state, and recipes that may or may not return a Promise, are also typed more precisely. Generic code has caveats, such as a state type that is itself a type parameter; see TypeScript in the migration guide.

Upgrading from 1.x

The fixes in 2.0 are not backported to 1.x, and 1.3.0 is the last 1.x release. Install 2.0:

npm install mutative@2

Libraries built on Mutative that declare mutative 1.x as a peer dependency make npm 7 and later stop with ERESOLVE when it installs mutative@2 beside them. Install versions of them that accept 2.0, or stay on 1.3.0 until they do.

Then work through these steps. The migration guide lists the changes behind each one.

  1. Run your tests with both builds: the development build, which reports the new checks, and the production build, with NODE_ENV=production. In production, match errors by their codes, not their messages. See Development builds.
  2. Check your bundles and targets. Define process.env.NODE_ENV in every bundle, including bundles for Node.js, or keep Mutative external. Load the production ESM or UMD build in browsers without a bundler, and transpile Mutative for engines without ES2018 syntax. See Build output and package entries.
  3. Update code and tests that read patches. Expect a new order in patch snapshots, and copy a patch value before changing it. See Patches.
  4. Review code that relies on auto-freeze. Frozen Maps and Sets no longer accept new properties or replaced methods, and a returned value must not hold drafts inside frozen objects. See Returned values.
  5. Respect draft lifetimes. Consume value-producing iterators while their draft is active, and use original(draft), current(draft) or an id rather than a draft as a Map key. A Set subclass's add() must store the value it receives; see collection lifetimes.
  6. Check arrays with getters on their indices, which the native methods read as plain values. See Arrays.
  7. Type-check your code, and revisit casts that worked around the async and apply() types of 1.x. The declarations support TypeScript 5.0 and later. See TypeScript.
  8. Recheck tests that pin results of 1.x, especially for Sets, patches, apply() and current(): the new result may be the intended one. See Fixes that change results.

Coming from Immer

Mutative passes Immer's test suite, adapted to its API, and most Immer code moves over by renaming: produce() becomes create(), produceWithPatches() becomes create() with enablePatches: true, applyPatches() becomes apply(), and nothing becomes rawReturn(undefined). Drop the plugin calls, since Mutative has no plugins, and replace [immerable] with a mark. The main decision is freezing: Immer freezes the next state by default and Mutative does not, so set enableAutoFreeze: true, for example with makeCreator(), if your code relies on frozen state. The migration guide from Immer walks through each step, Comparison with Immer lists every difference, checked against Immer 11.1.18, and mutative-compat wraps Mutative in Immer's API for a quicker switch; at the time of writing, it declares Mutative 1.x as its peer dependency.

Thanks

Thanks to everyone who reported issues, reviewed changes and built libraries on Mutative. The docs were revised alongside 2.0: their code examples run as tests of the library, and new guides cover pitfalls and performance tips. If something behaves differently after you upgrade, or the migration guide misses a case, please open an issue or start a discussion.

Tags:release
Edit on GitHub