Skip to content

remmi

Minified561 BMinzipped223 B

Reverse 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:

ts
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:

copywrite
ts
const before = [];

const after = [...before, 0];
after.push(1);
after.push(2);

before; //=> []
after; //=> [0, 1, 2]

This is what remmi allows you to do!

ts
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;
}
ts
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:

copywrite
ts
const a = [];
const b = push(a, 0);
const c = push(b, 1);
const d = push(c, 2);

a; //=> []
d; //=> [0, 1, 2]
ts
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)

namesummaryops/sectime/opmarginsamples
mutation🥇25M34ns±1.60%29M
remmi (withMutations)2.4x slower7M148ns±6.57%7M
copy + mutation4.2x slower5M253ns±8.84%4M
remmi4.9x slower4M256ns±0.77%4M
mutative161.3x slower155K7µs±0.85%148K
immer277.7x slower90K12µs±6.48%86K

replace(array, value, replacement)

Test: Replacing elements in a populated array (Apple M1 Max, Node v24.0.1)

namesummaryops/sectime/opmarginsamples
mutation🥇2K430µs±0.23%2K
remmi (withMutations)0.0x slower2K442µs±0.24%2K
remmi6.1x slower3303ms±0.26%331
copy + mutation6.2x slower3253ms±0.79%325
mutative163.4x slower1471ms±0.11%64
immer204.9x slower1188ms±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).

Setup
ts
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;
}
copywrite
Usage
ts
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

sh
npm install @monstermann/remmi
sh
pnpm add @monstermann/remmi
sh
yarn add @monstermann/remmi
sh
bun add @monstermann/remmi