remmi
Minified561 BMinzipped223 BReverse immer: Immutable by default, mutable when you need it.
Libraries like immer or mutative let you write code that appears to mutate data, but actually creates immutable updates under the hood.
remmi flips this approach: You write code that treats data as immutable by default, but can opt-in to efficient, copy-on-write mutations for performance-critical code paths.
Example
An example for a typical immutable push:
function push(target, value) {
return [...target, value];
}
const a = [];
const b = push(a, 0);
const c = push(b, 1);
const d = push(c, 2);
a; //=> []
d; //=> [0, 1, 2]But this copies the array every time, sometimes you might want to have something like this instead:
copywriteconst before = [];
const after = [...before, 0];
after.push(1);
after.push(2);
before; //=> []
after; //=> [0, 1, 2]This is what remmi allows you to do!
import { cloneArray } from "@monstermann/remmi";
function push(target, value) {
// Clone this array and mark it as mutable if we haven't done so already,
// otherwise keep it as-is:
target = cloneArray(target);
// Mutate it:
target.push(value);
return target;
}import { isMutable, markAsMutable } from "@monstermann/remmi";
function push(target, value) {
if (isMutable(target)) {
// If this array has been marked as mutable, then mutate it:
target.push(value);
return target;
} else {
// Otherwise clone it and mark it as mutable:
return markAsMutable([...target, value]);
}
}And now let's use it:
copywriteconst a = [];
const b = push(a, 0);
const c = push(b, 1);
const d = push(c, 2);
a; //=> []
d; //=> [0, 1, 2]const before = [];
const after = withMutations(() => {
const a = before;
const b = push(a, 0);
const c = push(b, 1);
const d = push(c, 2);
return d;
});
before; //=> []
after; //=> [0, 1, 2]Benchmarks
push(array, value)
Test: Pushing values into an empty array (Apple M1 Max, Node v24.0.1)
| name | summary | ops/sec | time/op | margin | samples |
|---|---|---|---|---|---|
| mutation | 🥇 | 25M | 34ns | ±1.60% | 29M |
remmi (withMutations) | 2.4x slower | 7M | 148ns | ±6.57% | 7M |
| copy + mutation | 4.2x slower | 5M | 253ns | ±8.84% | 4M |
| remmi | 4.9x slower | 4M | 256ns | ±0.77% | 4M |
| mutative | 161.3x slower | 155K | 7µs | ±0.85% | 148K |
| immer | 277.7x slower | 90K | 12µs | ±6.48% | 86K |
replace(array, value, replacement)
Test: Replacing elements in a populated array (Apple M1 Max, Node v24.0.1)
| name | summary | ops/sec | time/op | margin | samples |
|---|---|---|---|---|---|
| mutation | 🥇 | 2K | 430µs | ±0.23% | 2K |
remmi (withMutations) | 0.0x slower | 2K | 442µs | ±0.24% | 2K |
| remmi | 6.1x slower | 330 | 3ms | ±0.26% | 331 |
| copy + mutation | 6.2x slower | 325 | 3ms | ±0.79% | 325 |
| mutative | 163.4x slower | 14 | 71ms | ±0.11% | 64 |
| immer | 204.9x slower | 11 | 88ms | ±0.22% | 64 |
How it works
remmi uses a global context stack to track which objects are mutable within a given scope. When you enter a mutation context, you can mark values as mutable and mutate them in-place. Outside a context, all updates are persistent (immutable).
const contexts: WeakSet<unknown>[] = [];
function startContext(): void {
contexts.push(new WeakSet());
}
function endContext(): void {
contexts.pop();
}
function markAsMutable<T extends WeakKey>(value: T): T {
contexts.at(-1)?.add(value);
return value;
}
function isMutable<T extends WeakKey>(value: T): boolean {
return contexts.at(-1)?.has(value) === true;
}function push<T>(target: T[], value: T): T[] {
target = isMutable(target) ? target : markAsMutable([...target]);
target.push(value);
return target;
}
const a1 = [];
const a2 = push(a1, 0);
const a3 = push(a2, 1);
startContext();
const a4 = push(a3, 2);
const a5 = push(a4, 2);
const a6 = push(a5, 2);
endContext();
const a7 = push(a6, 2);
const a8 = push(a7, 2);Installation
npm install @monstermann/remmipnpm add @monstermann/remmiyarn add @monstermann/remmibun add @monstermann/remmi