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/remmi
pnpm add @monstermann/remmi
yarn add @monstermann/remmi
bun add @monstermann/remmi