Mutative - A JavaScript library for efficient immutable updates. In the October 3–4 benchmark, it was about 3x faster than Immer with the same settings and 6x faster with each library's defaults across 90 workloads.
In that benchmark the gap widened on large arrays, where Mutative moved elements up to 1,125x faster than Immer. When copying dominates, such as updating objects with thousands of keys or inserting at the front of a large array, Mutative is even faster than hand-written spread reducers.
How does Mutative compare with the spread operation (hand-written reducers)?
Mutative copies each object at most once per update, however many changes a recipe makes, and focuses on shallow copy optimization, more complete lazy drafts, finalization process optimization, and more.
Writing immutable updates by hand is usually difficult, prone to errors, and cumbersome. Immer helps us write simpler immutable updates with "mutative" logic.
But its performance issue causes a runtime performance overhead. Immer enables auto-freeze by default, and such frozen immutable state is not common. In scenarios such as cross-processing, remote data transfer, etc., these immutable data must be constantly frozen.
There are more parts that could be improved, such as better type inference, non-intrusive markup, support for more types of immutability, Safer immutability, more edge cases, and so on.
This is why Mutative was created.
Mutative passed all of Immer's test cases.
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 with Immer 11.1.18 and with reducers written by hand, after checking every result against those reducers. The performance summary has the complete results, the method, and their limits.
The cross-library figures below were measured on October 3–4, 2026, at source e6b6563. The PR #184 comparison separately measures the subsequent correctness fixes against their main baseline.
With matched settings, both freezing or both not and both generating patches or both not, Mutative was faster than Immer in 508 of 530 measured cases, 3.1x on geometric mean. With each library's defaults, Mutative without auto-freeze and Immer with it, Mutative was faster in 133 of 136 cases, 6.0x on geometric mean.
Times are microseconds per update, medians of three runs on an Apple M1 Max with Node.js 24.16.0; lower is better. Mutative, the first Immer column, and the hand-written reducers run without auto-freeze; the second Immer column shows Immer's default.
| Workload | Rows | Mutative | Immer | Immer, auto-freeze | Hand-written |
|---|---|---|---|---|---|
| Update one field of a small object | — | 0.65 | 1.02 | 1.32 | 0.05 |
| Update an array item found by ID | 100 | 1.33 | 1.99 | 25.9 | 0.84 |
| 200 RTK Query-style updates | — | 1,031 | 1,180 | 5,209 | 361 |
| Read every row by index | 10,000 | 5,210 | 11,253 | 11,513 | 135 |
| Remove the first row | 10,000 | 7.37 | 8,292 | 9,125 | 1.43 |
| Update every row | 10,000 | 7,008 | 12,803 | 16,454 | 243 |
| Update one of 10,000 records | 10,000 | 1,242 | 2,172 | 3,222 | 2,177 |
| Insert into a 1,000-property object | — | 53.0 | 151 | 238 | 131 |
| Update a Map value | 10,000 | 553 | 557 | 774 | 548 |
| Add a number to a Set | 10,000 | 1,013 | 1,502 | 1,605 | 72.4 |
| Update a class instance | 10,000 | 3.19 | 3.74 | 673 | 1.49 |
| Update a value ten levels deep | — | 3.70 | 4.75 | 8.27 | 0.87 |
| Apply patches to 10% of rows | 10,000 | 1,558 | 1,465 | 2,440 | — |
| Return draft.filter() | 10,000 | 2,444 | 4,188 | 4,645 | 80.2 |
| Return a new state | 10,000 | 2,914 | 8,272 | 0.89 | 0.06 |
| Return it with rawReturn() | 10,000 | 0.30 | 7,865 | 0.89 | 0.06 |
The record and 1,000-property rows ran each library in a process of its own, because code that ran earlier in a process changes how fast V8 copies such wide objects. Immer has no rawReturn() and returns the same plain value in the last two rows. Immer was faster in 11 of the 530 matched cases: when returning a new state built from frozen data, which Immer does not search for drafts; when applying patches that replace nested values, by 5-7%; and, with auto-freeze, when updating a class instance with 1,000 fields.
Run pnpm benchmark:immer to measure the suite; the benchmark guide describes its options.
At 1,000 and 10,000 rows the gap grows. Against Immer without its array-method plugin, Mutative was faster in 164 of 176 cases at these sizes, 3.5x on geometric mean, and faster in every case that moves elements:
| Auto-freeze | Patches | All workloads, 1,000 rows | All workloads, 10,000 rows | Moves, 1,000 rows | Moves, 10,000 rows |
|---|---|---|---|---|---|
| off | off | 7.3x | 8.8x | 212x | 472x |
| off | on | 3.9x | 4.2x | 6.7x | 7.7x |
| on | off | 2.6x | 2.3x | 32x | 35x |
| on | on | 1.9x | 1.7x | 6.2x | 6.8x |
Each value is the geometric mean of Immer's time over Mutative's; moves are shift, unshift, splice insertion, and reverse. Mutative moves elements natively on its copy, while Immer moves each one through its draft proxy: removing the first of 10,000 rows took 7.37 µs against 8,292 µs, 1,125x. With patches, both libraries emit one patch per moved index, which bounds the gain to 6-8x. The performance summary breaks these results down by scenario.
With patches on and auto-freeze off, Mutative was faster than Immer in 126 of 129 cases and never slower, 3.1x on geometric mean, and faster than Mutative 1.3.0 in 103 and within 5% in the rest, 3.7x. In every array case measured it was faster than both: 4.0x Immer and 13x Mutative 1.3.0 on geometric mean. Patches cost little when an update changes a few paths: pushing a row and inserting a property at 10,000 rows took 65.2 µs with patches against 64.8 µs without. Moving elements emits one patch per moved index in every library, so removing the first of 10,000 rows produces 10,000 forward and 10,000 inverse patches.
Times are microseconds per update with patches on and auto-freeze off:
| Workload | Rows | Mutative | Immer | Mutative 1.3.0 |
|---|---|---|---|---|
| Push a row and insert a property | 10,000 | 65.2 | 370 | 212 |
| Update an array item found by ID | 100 | 2.17 | 3.57 | 3.85 |
| 200 RTK Query-style updates | — | 1,325 | 1,520 | 2,172 |
Remove the first row with splice |
100 | 13.5 | 86.5 | 3,108 |
| Insert a row in the middle | 100 | 9.91 | 51.3 | 1,297 |
| Sort rows | 100 | 108 | 217 | 2,622 |
Remove the first row with shift |
10,000 | 1,227 | 10,117 | 282,588 |
| Reverse the rows | 10,000 | 1,226 | 10,167 | 286,207 |
| Update every row | 10,000 | 11,591 | 22,553 | 21,851 |
| Update one of 10,000 records | 10,000 | 1,236 | 2,165 | 1,230 |
Immer's optional enableArrayMethods() plugin also runs array methods on the draft's copy, and with patches it comes within 1.05-1.74x of Mutative when moving elements of 1,000 or 10,000 rows. These comparisons leave it off because in Immer 11.1.18 it breaks guarantees that Immer otherwise keeps: shift, pop and splice return raw base objects, so editing a removed object changes the previous state; reordering can expose original objects the same way and leave revoked drafts in the result; and its forward patches can fail to replay. In the audit, 123 of 4,913 three-step operation sequences break with the plugin and none without it. The performance summary reports Immer with the plugin separately.
Mutative ships patches, Map/Set support and native array methods built in; Immer provides them as opt-in plugins. The following Brotli sizes were measured with esbuild 0.24.0 from the ESM entry that bundlers resolve for each library (Immer 11.1.21 dist/immer.mjs; Mutative dist/mutative.esm.mjs at source 18a0ea7), with process.env.NODE_ENV defined as production, bundled for the browser with --minify --target=es2018 --format=esm. The first row imports only produce or create. The second adds applyPatches, current and original with Immer's enablePatches, enableMapSet and enableArrayMethods, and apply, current and original for Mutative.
| Bundle | Immer | Mutative |
|---|---|---|
produce / create only |
3.6 kB | 7.4 kB |
With patches, Map/Set and array methods |
6.4 kB | 7.9 kB |
Mutative's create includes patches, Map/Set support and the native array methods even when a recipe does not use them: they are part of create, not separate imports, so bundlers cannot drop them. The difference buys the draft fast paths and the native array methods measured in the performance summary, which also records the artifact sizes of each measured source. See the array methods FAQ for the supported fast paths and their contract, and the Immer regression cases for the behavior of its array-method plugin.
- Mutation makes immutable updates - Immutable data structures supporting objects, arrays, Sets and Maps.
- High performance - About 6x faster than Immer with each library's defaults in the October 3–4 benchmark, and faster than hand-written spreads in measured wide-object and large-array insertion workloads.
- Optional freezing state - No freezing of immutable data by default.
- Support for JSON Patch - Full compliance with JSON Patch specification.
- Custom shallow copy - Support for more types of immutable data.
- Support mark for immutable and mutable data - Allows for non-invasive marking.
- Safer mutable data access in strict mode - It brings more secure immutable updates.
- Support for reducer - Support reducer function and any other immutable state library.
| Mutative | Immer | |
|---|---|---|
| Custom shallow copy | ✅ | ❌ |
| Strict mode | ✅ | ❌ |
| No data freeze by default | ✅ | ❌ |
| Non-invasive marking | ✅ | ❌ |
| Complete freeze data | ✅ | ❌ |
| Non-global config | ✅ | ❌ |
| async draft function | ✅ | ❌ |
| Fully compatible with JSON Patch spec | ✅ | ❌ |
| new Set methods(Mutative v1.1.0+) | ✅ | ❌ |
Mutative has fewer bugs such as accidental draft escapes than Immer, view details.
Yarn
yarn add mutativeNPM
npm install mutativeCDN
- Unpkg:
<script src="https://unpkg.com/mutative"></script> - JSDelivr:
<script src="https://cdn.jsdelivr.net/npm/mutative"></script> - ES module:
import { create } from 'https://unpkg.com/mutative/dist/mutative.esm.production.min.mjs';
The package's other ESM files read process.env.NODE_ENV, which bundlers and Node.js provide; a browser without a bundler needs the production file above.
import { create } from "mutative";
const baseState = {
foo: "bar",
list: [{ text: "coding" }],
};
const state = create(baseState, (draft) => {
draft.list.push({ text: "learning" });
});
expect(state).not.toBe(baseState);
expect(state.list).not.toBe(baseState.list);create(baseState, (draft) => void, options?: Options): newState
The first argument of create() is the base state. Mutative drafts it and passes it to the arguments of the draft function, and performs the draft mutation until the draft function finishes, then Mutative will finalize it and produce the new state.
Use create() for more advanced features by setting options.
create()apply()current()original()unsafe()isDraft()isDraftable()rawReturn()makeCreator()markSimpleObject()
Use create() for draft mutation to get a new state, which also supports currying.
import { create } from "mutative";
const baseState = {
foo: "bar",
list: [{ text: "todo" }],
};
const state = create(baseState, (draft) => {
draft.foo = "foobar";
draft.list.push({ text: "learning" });
});In this basic example, the changes to the draft are 'mutative' within the draft callback, and create() is finally executed with a new immutable state.
Then options is optional.
-
strict -
boolean, the default is false.Forbid accessing non-draftable values in strict mode(unless using unsafe()).
When strict mode is enabled, mutable data can only be accessed using
unsafe().It is recommended to enable
strictin development mode and disablestrictin production mode. This will ensure safe explicit returns and also keep good performance in the production build. If the value that does not mix any current draft or isundefinedis returned, then use rawReturn().If you'd like to enable strict mode by default in a development build and turn it off for production, you can use
strict: process.env.NODE_ENV !== 'production'.In development builds, strict mode also warns once when a recipe leaves 1,000 or more drafts unchanged, as a search through a large draft array does. See
current()for searching without creating drafts. -
enablePatches -
boolean | { pathAsArray?: boolean; arrayLengthAssignment?: boolean; }, the default is false.Enable patch, and return the patches/inversePatches.
If you need to set the shape of the generated patch in more detail, then you can set
pathAsArrayandarrayLengthAssignment。pathAsArraydefault value istrue, if it'strue, the path will be an array, otherwise it is a string;arrayLengthAssignmentdefault value istrue, if it'strue, the array length will be included in the patches, otherwise no include array length(NOTE: IfarrayLengthAssignmentisfalse, it is fully compatible with JSON Patch spec, but it may have additional performance loss), view related discussions. -
enableAutoFreeze -
boolean, the default is false.Enable autoFreeze, and return frozen state, and enable circular reference checking only in
developmentmode. -
mark -
(target) => ('mutable'|'immutable'|function) | (target) => ('mutable'|'immutable'|function)[]Set a mark to determine if the value is mutable or if an instance is an immutable, and it can also return a shallow copy function(
AutoFreezeandPatchesshould both be disabled, Some patches operation might not be equivalent). When the mark function is (target) => 'immutable', it means all the objects in the state structure are immutable. In this specific case, you can totally turn onAutoFreezeandPatches.marksupports multiple marks, and the marks are executed in order, and the first mark that returns a value will be used. When a object tree node is marked by themarkfunction asmutable, all of its child nodes will also not be drafted by Mutative and will retain their original values.
- create
draft
const [draft, finalize] = create(baseState);
draft.foobar.bar = "baz";
const state = finalize();Support set options such as
const [draft, finalize] = create(baseState, { enableAutoFreeze: true });
- create
producer
const produce = create((draft) => {
draft.foobar.bar = "baz";
});
const state = produce(baseState);Also support set options such as
const produce = create((draft) => {}, { enableAutoFreeze: true });
Use apply() for applying patches to get the new state.
import { create, apply } from "mutative";
const baseState = {
foo: "bar",
list: [{ text: "todo" }],
};
const [state, patches, inversePatches] = create(
baseState,
(draft) => {
draft.foo = "foobar";
draft.list.push({ text: "learning" });
},
{
enablePatches: true,
},
);
const nextState = apply(baseState, patches);
expect(nextState).toEqual(state);
const prevState = apply(state, inversePatches);
expect(prevState).toEqual(baseState);The options parameter is optional and supports two types of configurations:
- Immutable options (similar to create options but without
enablePatches):strict-boolean, forbid accessing non-draftable values in strict modeenableAutoFreeze-boolean, enable autoFreeze and return frozen statemark- mark function to determine if a value is mutable/immutable
const baseState = { foo: { bar: "test" } };
// This will create a new state.
const result = apply(baseState, [
{
op: "replace",
path: ["foo", "bar"],
value: "test2",
},
]);
expect(baseState).not.toEqual({ foo: { bar: "test2" } });
expect(result).toEqual({ foo: { bar: "test2" } });- Mutable option(Mutative v1.2.0+):
mutable-boolean, if true the state will be mutated directly instead of creating a new state
Example with mutable option:
const baseState = { foo: { bar: "test" } };
// This will modify baseState directly
apply(
baseState,
[
{
op: "replace",
path: ["foo", "bar"],
value: "test2",
},
],
{
mutable: true,
},
);
expect(baseState).toEqual({ foo: { bar: "test2" } });
⚠️ Note: The mutable option cannot be combined with other options. When using mutable option, apply() will return void instead of a new state.
Patches add and remove Set elements by value, also a changed element of a Set that added or removed elements, and
apply()copies patch values, so inverse patches cannot remove an object thatapply()added to a Set, as in an undo after a redo. See Sets of objects.
Get the current value from a draft.
- For any draft where a child node has been modified, the state obtained by executing current() each time will be a new reference object.
- For a draft where no child nodes have been modified, executing current() will always return the original state.
It is recommended to minimize the number of times current() is executed when performing read-only operations, ideally executing it only once.
const state = create({ a: { b: { c: 1 } }, d: { f: 1 } }, (draft) => {
draft.a.b.c = 2;
expect(current(draft.a)).toEqual({ b: { c: 2 } });
// The node `a` has been modified.
expect(current(draft.a) === current(draft.a)).toBeFalsy();
// The node `d` has not been modified.
expect(current(draft.d) === current(draft.d)).toBeTruthy();
});current() is also the cheap way to search a large array of objects. Every object read through a draft becomes a draft of its own, so draft.list.find() pays for a draft per visited element. current(draft.list) is the original array while the recipe has not changed it; otherwise it is a copy that holds the current value of each changed element and the original object of every other one. Its indices are those of the draft, also after the recipe added, removed or moved elements. Search it and change the match through the draft. Its elements are not drafts, so the callback must only read them.
const state = create(baseState, (draft) => {
const index = current(draft.list).findIndex((item) => item.text === "todo");
draft.list[index].done = true;
});Get the original value from a draft.
const baseState = {
foo: "bar",
list: [{ text: "todo" }],
};
const state = create(baseState, (draft) => {
draft.foo = "foobar";
draft.list.push({ text: "learning" });
expect(original(draft.list)).toEqual([{ text: "todo" }]);
});original() reflects the state before the recipe's changes, so an index found in original(draft.list) no longer matches the draft once the recipe has added, removed or moved elements. To search a draft array, use current().
When strict mode is enabled, mutable data can only be accessed using unsafe().
const baseState = {
list: [],
date: new Date(),
};
const state = create(
baseState,
(draft) => {
unsafe(() => {
draft.date.setFullYear(2000);
});
// or return the mutable data:
// const date = unsafe(() => draft.date);
},
{
strict: true,
},
);If you'd like to enable strict mode by default in a development build and turn it off for production, you can use
strict: process.env.NODE_ENV !== 'production'.
Check if a value is a draft.
const baseState = {
date: new Date(),
list: [{ text: "todo" }],
};
const state = create(baseState, (draft) => {
expect(isDraft(draft.date)).toBeFalsy();
expect(isDraft(draft.list)).toBeTruthy();
});Check if a value is draftable
const baseState = {
date: new Date(),
list: [{ text: "todo" }],
};
expect(isDraftable(baseState.date)).toBeFalsy();
expect(isDraftable(baseState.list)).toBeTruthy();You can set a mark to determine if the value is draftable, and the mark function should be the same as passing in
create()mark option.
For return values that do not contain any drafts, you can use rawReturn() to wrap this return value to improve performance. It ensure that the return value is only returned explicitly.
Mutative searches the value a recipe returns for drafts, so that drafts mixed into it are replaced. With auto-freeze enabled, production builds do not search frozen objects or the values they hold, so returning an earlier, frozen state, or state built from one, costs little even without rawReturn(). Development builds still search them and throw when they find a draft there, so never put a draft in a frozen object or in a value it holds.
const baseState = { id: "test" };
const state = create(baseState as { id: string } | undefined, (draft) => {
return rawReturn(undefined);
});
expect(state).toBe(undefined);If the return value mixes drafts, you should not use
rawReturn().
const baseState = { a: 1, b: { c: 1 } };
const state = create(baseState, (draft) => {
if (draft.b.c === 1) {
return {
...draft,
a: 2,
};
}
});
expect(state).toEqual({ a: 2, b: { c: 1 } });
expect(isDraft(state.b)).toBeFalsy();If you use rawReturn(), we recommend that you enable strict mode in development.
const baseState = { a: 1, b: { c: 1 } };
const state = create(
baseState,
(draft) => {
if (draft.b.c === 1) {
return rawReturn({
...draft,
a: 2,
});
}
},
{
strict: true,
},
);
// it will warn `The return value contains drafts, please don't use 'rawReturn()' to wrap the return value.` in strict mode.
expect(state).toEqual({ a: 2, b: { c: 1 } });
expect(isDraft(state.b)).toBeFalsy();makeCreator() only takes options as the first argument, resulting in a custom create() function.
const baseState = {
foo: {
bar: "str",
},
};
const create = makeCreator({
enablePatches: true,
});
const [state, patches, inversePatches] = create(baseState, (draft) => {
draft.foo.bar = "new str";
});markSimpleObject() is a mark function that marks all objects as immutable.
const baseState = {
foo: {
bar: "str",
},
simpleObject: Object.create(null),
};
const state = create(
baseState,
(draft) => {
draft.foo.bar = "new str";
draft.simpleObject.a = "a";
},
{
mark: markSimpleObject,
},
);
expect(state.simpleObject).not.toBe(baseState.simpleObject);castDraft()castImmutable()castMutable()Draft<T>Immutable<T>PatchesPatchOptions<O, F>
- use-mutative - A 2-6x faster alternative to useState with spread operation
- use-travel - A React hook for state time travel with undo, redo, reset and archive functionalities.
- zustand-mutative - A Mutative middleware for Zustand enhances the efficiency of immutable state updates.
- I'm already using Immer, can I migrate smoothly to Mutative?
Yes. Unless you have to be compatible with Internet Explorer, Mutative supports almost all of Immer features, and you can easily migrate from Immer to Mutative.
Migration is also not possible for React Native that does not support Proxy. React Native uses a new JS engine during refactoring - Hermes, and it (if < v0.59 or when using the Hermes engine on React Native < v0.64) does not support Proxy on Android, but React Native v0.64 with the Hermes engine support Proxy.
- Can Mutative be integrated with Redux?
Yes. Mutative supports return values for reducer, and redux-toolkit is considering support for configurable produce().
- Which array methods run natively on drafts?
shift, unshift, splice and reverse move elements directly on the copy of a plain array without holes, including undefined elements; indexOf, lastIndexOf and includes search the current array natively; sort and join run natively on arrays of primitives. Removed elements are returned as drafts, patches replay in both directions, and a call that changes nothing keeps the state. Sparse arrays, array subclasses, an own constructor or Symbol.isConcatSpreadable, arrays under a custom mark, every method with a callback, at, slice, fill and copyWithin use the proxy path. An argument that can run user code, such as an object passed as fromIndex or as a splice index, is converted on the proxy path, so a conversion that changes the array is observed as the native methods observe it. The fast paths are made for data arrays: an accessor property defined on an array index is read as a data value, and the number and order of such getter calls, their re-entrant effects on the draft, and the identity of objects they return are not guaranteed to match element-by-element execution through the proxy. With auto-freeze, Mutative may also read such a getter twice when it copies a frozen, sealed or non-extensible array.
Optimized searches give the same results as the proxy path, comparing elements as a read returns them. Drafts, values assigned in the recipe, non-draftable objects and primitives are found. An object of the base state is drafted when it is read, so it is never found, whether or not it was read before; use original() to search the base state, for example original(draft.list).indexOf(item). The search never reads a property of the value it is given.
In strict mode, outside unsafe(), optimized calls on an array that may hold objects take the proxy path unchanged, so reading a non-draftable element fails exactly as it always did; arrays of primitives, recognized with typeof alone, keep the native paths. Elements are moved and compared without being inspected, while the proxy path inspects each element it reads. An element that is itself a Proxy may therefore see fewer calls to its internal methods on the native paths, never more and never at other times; a revoked Proxy element that a search passes over, for example, does not throw there.
Draftable base elements removed or moved by these methods are drafted before they are exposed. Methods with callbacks, such as forEach, map, filter and find, go through the draft so that their callbacks see every change and can modify elements; use current() for read-only scans of large arrays.
- Can a recipe change a Map draft while iterating over it?
Yes. Deleting the entry being visited and changing the values of other entries work as on a Map, and Set drafts iterate like Sets. One difference remains: an iteration over a Map draft that starts before the recipe has changed that Map, or read an object value from it, walks the entries of the base Map. An entry that the recipe deletes later in such an iteration is still visited, with undefined as its value, and an entry that it adds is not visited. To delete other entries while iterating, iterate over Array.from(draft.keys()) and skip the keys for which draft.has() returns false.
- Does Mutative support shared references?
Yes, Mutative supports shared references, but each path to a shared object gets its own independent draft. Modifications to one path do not automatically reflect in others. If you want to preserve shared references in the result, you must explicitly assign them (e.g., draft.b = draft.a). Read more details.
mutative-compat - Mutative wrapper with full Immer API compatibility, you can use it to quickly migrate from Immer to Mutative.
produce()->create()
Mutative auto freezing option is disabled by default, Immer auto freezing option is enabled by default.
You need to check if auto freezing has any impact on your project. If it depends on auto freezing, you can enable it yourself in Mutative.
import produce from "immer";
const nextState = produce(baseState, (draft) => {
draft[1].done = true;
draft.push({ title: "something" });
});Use Mutative
import { create } from "mutative";
const nextState = create(baseState, (draft) => {
draft[1].done = true;
draft.push({ title: "something" });
});Patches
import { produceWithPatches, applyPatches } from "immer";
enablePatches();
const baseState = {
age: 33,
};
const [nextState, patches, inversePatches] = produceWithPatches(
baseState,
(draft) => {
draft.age++;
},
);
const state = applyPatches(nextState, inversePatches);
expect(state).toEqual(baseState);Use Mutative
import { create, apply } from "mutative";
const baseState = {
age: 33,
};
const [nextState, patches, inversePatches] = create(
baseState,
(draft) => {
draft.age++;
},
{
enablePatches: true,
},
);
const state = apply(nextState, inversePatches);
expect(state).toEqual(baseState);- Return
undefined
import produce, { nothing } from "immer";
const nextState = produce(baseState, (draft) => {
return nothing;
});Use Mutative
import { create, rawReturn } from "mutative";
const nextState = create(baseState, (draft) => {
return rawReturn(undefined);
});Mutative v2 keeps the v1 API. The changes below, made since v1.3.0, can affect existing code.
- ES2018 output. Every build targets ES2018 instead of ES2015, so object spread stays native. Engines that support 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.
- Bundlers. The ESM entry compares
process.env.NODE_ENVat each development check instead of always running development code. Production bundles, in which the bundler definesNODE_ENVasproduction, drop the development checks and warnings and throw minified errors, as CommonJS consumers already did. Bundles that target Node.js without definingNODE_ENV, such as esbuild withplatform: 'node', now include both CommonJS builds; defineNODE_ENVor keep Mutative external. - Node.js ESM. In Node.js,
importresolves a module that re-exports the CommonJS build, soimportandrequireshare one instance and both followNODE_ENV. Previously,importalways ran the development ESM build as a separate instance. - Browsers without a bundler.
dist/mutative.esm.mjsanddist/mutative.esm.jsreadprocess.env.NODE_ENV, so a page that loads them directly, for example from a raw CDN URL, fails withprocess is not defined. Loaddist/mutative.esm.production.min.mjsinstead, or the UMD build that theunpkgandjsdelivrfields point to. - Production errors. Production builds throw
Minified Mutative error #<code>with a link to the errors page instead of the full message, also forapply(),original()andrawReturn(). Development builds keep the messages. Code that matches error messages in production must match the codes instead.
- Order. The patches of a nested draft now come before those of its parents, the order Immer uses. For example,
const item = draft.list[0]; draft.list.length = 0; item.text = 'b'; draft.item = item;yields thelistlength replace before theitemadd. Applying the patches gives the same state as before, and replace patches that only restated an unchanged draft at its original index are no longer emitted. - Values. A patch value that was a draft is now the object that the next state holds instead of a deep copy, and it is frozen with the state when
enableAutoFreezeis on.apply()still copies patch values before applying them. Copy a patch value before mutating it. - Moved drafts. A changed draft that leaves its key, because
sort()or an assignment moved it or another value replaced it there, no longer emits patches under its old path; the patches of its parent carry its value. In v1, the patches of such a recipe could fail to apply, for example the inverse patches once a primitive took the old key. - Set items. A changed item of a Set that also added or removed items is carried by the Set's
removeandaddpatches. v1 also emitted patches under the item's position in the changed Set, which replay applied to whatever sat at that position in the base Set, or could not apply at all. A Set that only changed its items keeps the patches under each item's position. - Original objects assigned over drafts. Assigning the original object of a draft to the key that holds the draft is recorded like any other assignment: when the draft was read at that key, the key holds its original value again and emits no patch; when the draft was moved there from another key, the key emits the patch of that assignment. In v1, the first case emitted a
removepatch for a key the state kept, and the second emitted none, so replaying the patches dropped the key or the element.
shift,unshift,spliceandreverserun natively on the draft's copy of a plain array, andindexOf,lastIndexOfandincludessearch it natively; see which array methods run natively. For data arrays, the results are those of v1 and the patches replay in both directions. An accessor property on an array index is read as a data value, so its getter may run a different number of times.current()of a changed array copies the current array instead of reading every element through the draft. Base elements that a native method moved and that hold drafts of an outercreate()call are left as they are, as unmoved elements always were.- To search a large array without drafting each element it visits, search
current(draft.list)and change the match through the draft; seecurrent().
- With
enableAutoFreeze, production builds no longer search frozen objects in a value returned from a recipe, the returned value included, or the values they hold, for drafts, and leave a draft there unresolved. Development builds still search them and throw when they find one, also in an unfrozen object that a frozen one holds, where v1 replaced it. Never put a draft in a frozen object or in a value it holds. Without auto-freeze, returned values are searched as in v1.
- In strict mode, development builds warn once when a recipe leaves 1,000 or more drafts unchanged, as a search through a large draft array does.
- In strict mode,
rawReturn()of a value without drafts no longer prints contradictory warnings.
- With
enablePatches, every changed item of a Set keeps its changes. In v1, when two or more items of a Set that was not the root changed below their first level, the state kept the change of only one of them. - A Set that receives an unchanged draft holds the original object, as the rest of the state does, instead of the draft's shallow copy.
- Assigning
undefinedto a key thatdelete,shift,unshiftor a shrinkingspliceremoved from a draft adds the key back; v1 left a hole or kept the shorter length. - Under a
markthat returnsmutable, a value that the recipe assigned or moved is read back as assigned, through the draft and incurrent(); v1 returned the original value. current()of a draft whose state holds a plain Set with drafts returns a snapshot; v1 threw.- With
enableAutoFreeze, Map and Set instances are frozen too, so adding a property or replacing a method fails as on any frozen object; v1 only replaced their mutators. Later producers skip a frozen Map or Set instead of walking its entries again, and a Map or Set that holds itself no longer overflows the stack in production builds. - The iterators that Map and Set drafts return behave like built-in iterators: iterating one that was partly consumed continues where it stopped, and iterator helpers such as
toArray()are available where the engine has them. In v1, iterating such an iterator started over, and only a Map'skeys()had the helpers. - Consume Map value and entry iterators, and all Set iterators, while their draft is active. After the producer finishes or fails, or a manual draft is finalized, an iterator cannot yield another value and throws a
TypeError. Lazy iterator helpers follow the same lifetime; an exhausted iterator stays exhausted. Map keys are not drafted, andkeys()continues to return a native iterator. apply()copies the own symbol keys of patch values, and an own__proto__key, asJSON.parse()creates one, stays a data property. v1 dropped symbol keys there and turned an own__proto__key into the prototype of the copy.- A patch for a Map key that is an array holds the key as one path segment; v1 spread the array into the path, so applying the patch wrote to other keys.
- Array patches record a change between
0and-0, which the state already kept; in v1, applying the patches lost the sign. An element that staysNaNno longer yields a replace patch. - In strict mode, a nested
unsafe()call no longer ends the access of the outer call; in v1, reading mutable data after it in the outer callback threw. - A producer that fails after its recipe returned, for example because the recipe changed the draft and returned another value, revokes its drafts and releases its array method cache, as a recipe that throws does; v1 left them usable. This also covers errors while inspecting a returned Proxy or calling a returned Promise's
thenmethod, and preserves the original error. - A draft of an array whose
Symbol.isConcatSpreadableis false copies its elements; v1 put the whole array into the copy as its only element.
Mutative goal is to provide efficient and immutable updates. The focus is on performance improvements and providing better APIs for better development experiences. We are still working on it and welcome PRs that may help Mutative.
Development Workflow:
See Building and validating Mutative for the build pipeline, package checks, and bundle-size regression policy.
See the benchmark suite for the comparison of the current build with Mutative 1.3.0, Immer, and a hand-written reducer, matched freeze and patch modes, memory measurements, and CI regression budgets. Run pnpm benchmark:immer:check to validate every workload and pnpm benchmark:immer to measure them.
- Clone Mutative repo.
- Run
pnpm installto install all the dependencies. - Run
pnpm formatto format the code. pnpm test --watchruns an interactive test watcher.- Run
pnpm committo make a git commit.
Mutative is MIT licensed.
