immu-js
A tiny (~2 KB), zero-dependency, framework-independent, store based, reactive state library for JavaScript applications. It's very similar to redux in terms of functionality, but it's much simpler to work with immutable objects.
Why immu-js over redux or zustand or signals?
- Easier and Simpler: immu-js is much easier and simpler to work with than redux. No actions to dispatch. Automatic tracking (no need to pass dependencies to effects and memos).
- Store-based: Unlike signals which are value based, immu-js is store-based. It lets you group multiple states into one store and import it into wherever you need. It's very convinient.
- More options: immu-js has more options for effects and memos than signals. like you can control when you want to run Effect function by passing dependencies to it which are optional. If no deps passed, then it'll call the effect function when any stores that are used change. if you pass the dependencies, effects are run only if they change.
- Framework-independent: immu-js is framework-independent, which means you can use it with any framework or library but you will need adapters for frameworks if you want to use it with them.
- Zero-dependency: immu-js has zero-dependency, which means you don't need to install any other packages to use it.
- TypeScript support: immu-js has full TypeScript support and type inference.
- 100% test coverage: immu-js has 100% test coverage, which means you can trust it to work as expected.
- Small bundle size: immu-js is very small around 2kb.
How to use?
create function to create a store from a class.
run to run effects (similar to effect in signals).
memo to create a computed getter that lets you pass arguments to it and caches the computed result based on stores used in the function and also arguments passed to that function.
computed to create a signals like computed object with value property that has auto computed property. You can also save the store as a version so you can roll back to previous versions.
reset function to reset your stores. That's it.
How does it work?
It creates getters and setters for the store properties to track when they are read or written.
You have to assign new values to the properties in the class to detect the change. Library creates a getter and setter for each and every property in the class after creating an instance to track when they are read or written, so by setting the value through function or from outside, library will know that this value has changed. It treats your store values as immutable. so you will need to reacreate the references for the values at the root level when anything inside the object or array changes. values can be of any type.
import { create, memo, computed, run } from "immu-js";
class Counter {
count = 0;
address = {
street: "test",
}
incr() { this.count++; }
decr() { this.count--; }
setStreet(street: string){
this.address = { ...this.address, street };
}
getStreet(){
return this.address.street;
}
}
const store = create(Counter);
const doubled = memo((n: number) => store.count * n);
doubled(2);
store.incr();
doubled(2);
doubled(2);
const label = computed(() => `Count is ${store.count}`);
label.value;
store.incr();
label.value;
label.value;
const dispose = run(() => {
console.log("count changed:", store.count);
});
store.incr();
dispose();
const unsub = store._subscribe(() => console.log("count!", store.count), "count");
store.incr();
unsub();
store._saveVersion("checkpoint");
store.incr();
store.incr();
store.count;
store._loadVersion("checkpoint");
store.count;
store._reset();
store.count;
Table of Contents
Install
npm install immu-js
ESM and CJS builds are both included:
import { create, memo, computed, run } from "immu-js";
const { create, memo, computed, run } = require("immu-js");
Quick Start
import { create, memo, run } from "immu-js";
class Counter {
count = 0;
incr() { this.count++; }
decr() { this.count--; }
}
const store = create(Counter);
const dispose = run(() => {
console.log("count:", store.count);
});
store.incr();
const doubled = memo((multiplier: number) => store.count * multiplier);
doubled(2);
doubled(2);
store.incr();
doubled(2);
dispose();
API Reference
create(Class)
Creates a reactive store from a class constructor. All data properties are converted to reactive getters/setters. Methods (both prototype and arrow functions) remain callable and operate on the reactive state.
import { create } from "immu-js";
class TodoStore {
todos: string[] = [];
filter = "all";
addTodo(text: string) {
this.todos = [...this.todos, text];
}
setFilter(f: string) {
this.filter = f;
}
}
const store = create(TodoStore);
store.addTodo("Buy milk");
store.todos;
Parameters:
Class | new () => T | A class with a no-arg constructor |
Returns: Store<T> — the class instance augmented with _version, _subscribe, _reset, _saveVersion, and _loadVersion.
Store extras (_version, _subscribe, etc.) are non-enumerable — they won't appear in Object.keys(), JSON.stringify(), or for...in loops.
store._version
A read-only integer that increments on every property set. Useful for cache invalidation.
const store = create(Counter);
store._version;
store.incr();
store._version;
store.incr();
store._version;
Each individual property assignment increments the version independently:
class Multi { a = 1; b = 2; }
const store = create(Multi);
store.a = 10;
store.b = 20;
_version is readonly — attempting to set it throws a TypeError.
store._subscribe(cb, prop?)
Subscribe to store changes. Returns an unsubscribe function.
const store = create(Counter);
const unsub = store._subscribe(() => {
console.log("store changed, count is", store.count);
});
store.incr();
unsub();
Prop-specific subscriptions — pass a property name to only be notified when that specific property changes:
class Multi { a = 1; b = 2; }
const store = create(Multi);
store._subscribe(() => console.log("a changed!"), "a");
store._subscribe(() => console.log("b changed!"), "b");
store.a = 10;
store.b = 20;
Parameters:
cb | () => void | Callback invoked on change |
prop | string (optional) | Only notify when this property changes |
Returns: () => void — call to unsubscribe.
Important: Notifications are asynchronous — they fire on the next microtask via queueMicrotask, not synchronously on set. Multiple mutations in the same tick are batched into a single notification.
store._reset()
Resets all data properties to their initial values by re-instantiating the class internally. Methods are not affected.
const store = create(Counter);
store.incr();
store.incr();
store.count;
store._reset();
store.count;
- Goes through setters, so subscribers are notified and
_version increments.
- Works with objects, arrays, and inherited properties.
class AddressStore {
address = { city: "Delhi", zip: "110001" };
changeCity(city: string) {
this.address = { ...this.address, city };
}
}
const store = create(AddressStore);
store.changeCity("Mumbai");
store._reset();
store.address.city;
store._saveVersion(label?)
Snapshots the current state. Uses structuredClone for deep copying — saved state is fully independent of the live store.
const store = create(Counter);
store.incr();
store.incr();
store._saveVersion();
store._saveVersion("v1");
Parameters:
label | string | "default" | Label to identify this snapshot |
- Saving with the same label overwrites the previous snapshot.
- Nested objects and arrays are deep-cloned — mutations after saving don't affect the snapshot.
store._loadVersion(label?)
Restores a previously saved snapshot. Goes through setters, so subscribers are notified and _version increments.
const store = create(Counter);
store.incr();
store.incr();
store._saveVersion();
store.incr();
store.incr();
store.count;
store._loadVersion();
store.count;
Named versions:
store.incr();
store._saveVersion("v1");
store.incr();
store.incr();
store._saveVersion("v2");
store._loadVersion("v1");
store.count;
store._loadVersion("v2");
store.count;
Parameters:
label | string | "default" | Label of the snapshot to restore |
Throws: Error if no snapshot exists for the given label.
store._loadVersion("nonexistent");
memo(fn)
Creates a memoized function that automatically tracks which stores are accessed during execution. Returns a cached result when neither the arguments nor the tracked store versions have changed.
import { memo } from "immu-js";
const store = create(Counter);
const doubled = memo((multiplier: number) => store.count * multiplier);
doubled(2);
doubled(2);
doubled(3);
store.incr();
doubled(2);
doubled(2);
Parameters:
fn | (...args: TArgs) => TReturn | Function to memoize |
Returns: (...args: TArgs) => TReturn — memoized version of fn.
How caching works:
- Arguments are compared via
JSON.stringify — if the serialized args string matches, args are considered equal.
- Each tracked store's
_version is compared — if any store's version changed, the function recomputes.
- If both args and all store versions match, the cached result is returned.
No-argument memo:
const total = memo(() => store.count * 10);
total();
total();
Multi-argument memo:
const fmt = memo((a: number, b: string) => `${store.count * a}-${b}`);
fmt(2, "x");
Multiple stores:
const counter = create(Counter);
const users = create(UserStore);
const summary = memo(() => `${counter.count} users: ${users.users.join(", ")}`);
summary();
counter.incr();
summary();
users.addUser("Alice");
summary();
computed(fn)
Creates a lazily-evaluated, cached computed value. Similar to memo but with no arguments — access the result via .value.
import { computed } from "immu-js";
const store = create(Counter);
const doubled = computed(() => store.count * 2);
doubled.value;
doubled.value;
store.incr();
doubled.value;
doubled.value;
Parameters:
fn | () => T | Pure function deriving a value from store state |
Returns: Computed<T> — an object with a reactive value getter.
interface Computed<T> {
readonly value: T;
}
Multiple stores:
const storeA = create(Counter);
const storeB = create(Multi);
const combined = computed(() => storeA.count + storeB.a + storeB.b);
combined.value;
storeA.incr();
combined.value;
run(cb, deps?)
Runs a callback immediately, then re-runs it reactively whenever tracked stores change. Returns a dispose function to stop the effect.
import { run } from "immu-js";
const store = create(Counter);
const dispose = run(() => {
console.log("count:", store.count);
});
store.incr();
store.incr();
dispose();
store.incr();
Parameters:
cb | () => void | Effect callback |
deps | () => unknown[] (optional) | Dependency function for fine-grained control |
Returns: () => void — call to dispose the effect.
Automatic store tracking:
run tracks which stores are accessed inside cb. It only re-runs when those specific stores change — unrelated stores are ignored.
const counter = create(Counter);
const users = create(UserStore);
run(() => {
console.log("count:", counter.count);
});
users.addUser("Alice");
counter.incr();
With deps for fine-grained control:
When deps is provided, the callback only re-runs if the deps array values change (shallow comparison). This lets you decouple "what triggers re-run" from "what the callback reads."
const store = create(Multi);
run(
() => {
console.log("a is", store.a);
},
() => [store.a]
);
store.b = 99;
store.a = 10;
Deps can track different stores than the callback:
const storeA = create(Counter);
const storeB = create(Counter);
run(
() => console.log("A:", storeA.count),
() => [storeB.count]
);
storeB.incr();
Batching:
Multiple synchronous mutations are batched — the effect fires only once per flush:
const store = create(Counter);
const values: number[] = [];
run(() => values.push(store.count));
store.incr();
store.incr();
store.incr();
Patterns & Recipes
Multiple Stores
Each create() call produces an independent store. They can be used together in memo, computed, and run:
class AuthStore {
user: string | null = null;
login(name: string) { this.user = name; }
logout() { this.user = null; }
}
class CartStore {
items: string[] = [];
add(item: string) { this.items = [...this.items, item]; }
clear() { this.items = []; }
}
const auth = create(AuthStore);
const cart = create(CartStore);
const summary = memo(() => {
if (!auth.user) return "Not logged in";
return `${auth.user}'s cart: ${cart.items.join(", ") || "(empty)"}`;
});
summary();
auth.login("Alice");
summary();
cart.add("Book");
summary();
Nested Memo / Computed
memo and computed support nesting — inner dependencies bubble up to outer computations:
const store = create(Counter);
const level1 = memo(() => store.count * 2);
const level2 = memo(() => level1() + 10);
const level3 = memo(() => level2() + 100);
level3();
store.incr();
level3();
Mixing computed and memo:
const store = create(Counter);
const c1 = computed(() => store.count + 1);
const m1 = memo(() => c1.value * 2);
const c2 = computed(() => m1() + 100);
c2.value;
store.incr();
c2.value;
Class Inheritance
create walks the full prototype chain, picking up properties and methods from parent classes:
class Base {
baseCount = 0;
baseName = "base";
incrBase() { this.baseCount++; }
}
class Child extends Base {
childCount = 10;
incrChild() { this.childCount++; }
}
class GrandChild extends Child {
grandValue = "hello";
setGrand(val: string) { this.grandValue = val; }
}
const store = create(GrandChild);
store.baseCount;
store.childCount;
store.grandValue;
store.incrBase();
store._version;
store._reset();
store.baseCount;
store.incrBase();
store._saveVersion("snap");
store.incrBase();
store._loadVersion("snap");
store.baseCount;
Immutable Updates for Objects & Arrays
Always produce new references when updating objects or arrays — the setter only fires when you assign to the property:
class AddressStore {
address = { city: "Delhi", zip: "110001" };
changeCity(city: string) {
this.address = { ...this.address, city };
}
}
class UserStore {
users: string[] = [];
addUser(user: string) {
this.users = [...this.users, user];
}
}
Snapshot / Time-Travel
Use _saveVersion and _loadVersion for undo/redo or checkpoint patterns:
const store = create(Counter);
store.incr();
store._saveVersion("step1");
store.incr();
store._saveVersion("step2");
store.incr();
store._saveVersion("step3");
store._loadVersion("step1");
store.count;
store._loadVersion("step3");
store.count;
Edge Cases
Batched Notifications
Multiple synchronous mutations produce a single subscriber notification per flush:
const store = create(Counter);
const cb = vi.fn();
store._subscribe(cb);
store.incr();
store.incr();
store.incr();
Dispose Before Flush
If you dispose a run effect before the microtask flush, the callback will not re-execute:
const store = create(Counter);
const values: number[] = [];
const dispose = run(() => values.push(store.count));
store.incr();
dispose();
Dispose During Flush
If another subscriber disposes a run effect during the same flush, the disposed effect's callback is skipped:
const store = create(Counter);
let dispose: () => void;
store._subscribe(() => dispose());
dispose = run(() => {
console.log(store.count);
});
store.incr();
Calling Dispose Multiple Times
Calling dispose() more than once is safe — it's a no-op after the first call:
const dispose = run(() => { void store.count; });
dispose();
dispose();
Empty Class
Creating a store from a class with no properties works fine:
class Empty {}
const store = create(Empty);
store._version;
Symbol Properties
Symbol-keyed properties are skipped during reactive setup — they remain on the instance but are not reactive:
const sym = Symbol("test");
class WithSymbol {
count = 0;
[sym] = "symbol-value";
}
const store = create(WithSymbol);
store.count;
(store as any)[sym];
Overwriting Saved Versions
Saving with the same label overwrites the previous snapshot:
const store = create(Counter);
store.incr();
store._saveVersion("x");
store.incr();
store.incr();
store._saveVersion("x");
store._loadVersion("x");
store.count;
Loading Non-Existent Versions
Throws an error with a descriptive message:
store._loadVersion("nonexistent");
Deep Clone on Save/Load
Snapshots use structuredClone — saved state is fully independent. Mutating the store after saving doesn't affect the snapshot, and loading doesn't share references:
const store = create(AddressStore);
store.changeCity("Mumbai");
store._saveVersion();
store.changeCity("Kolkata");
store._loadVersion();
store.address.city;
Arrow Functions vs Prototype Methods
Both work. Arrow functions are instance properties (captured this), prototype methods use the instance as this:
class ArrowStore {
count = 0;
incr = () => { this.count++; };
getCount = () => { return this.count; };
}
class ProtoStore {
count = 0;
incr() { this.count++; }
}
Both patterns are fully supported by create.
Caveats
1. Notifications Are Asynchronous
Subscriber callbacks fire on the next microtask (via queueMicrotask), not synchronously when a property is set. If you need to read the updated state immediately after a mutation, read the property directly — don't rely on the subscriber having fired.
store.incr();
2. Mutating Nested Objects In-Place Won't Trigger Reactivity
The reactivity system tracks property assignments on the store, not deep mutations. Mutating a nested object in-place will not trigger subscribers or increment _version:
store.address.city = "Mumbai";
store.address = { ...store.address, city: "Mumbai" };
The same applies to arrays:
store.users.push("Alice");
store.users = [...store.users, "Alice"];
3. memo Uses JSON.stringify for Argument Comparison
Arguments are serialized with JSON.stringify. This means:
- Functions,
undefined, and Symbol values are dropped or converted to null during serialization.
- Circular references will throw.
- Object key order matters —
{a:1, b:2} and {b:2, a:1} produce different strings.
- For best performance, prefer primitives as memo arguments.
memo((obj) => obj.x)({ x: 1 });
memo((obj) => obj.x)({ x: 1 });
4. memo Caches Only the Last Call
memo stores a single cached result (the most recent args + store versions). Alternating between different argument sets will cause recomputation every time:
const doubled = memo((n: number) => store.count * n);
doubled(2);
doubled(3);
doubled(2);
5. Class Must Have a No-Argument Constructor
create calls new Class() with no arguments. If your class requires constructor parameters, it won't work:
class Store {
constructor(public initialCount: number) {}
}
class Store {
count = 0;
}
6. _reset Re-Instantiates the Class
_reset() creates a new instance of the original class to read fresh default values. If your class constructor has side effects (API calls, timers, etc.), they will execute again on reset.
7. structuredClone Limitations on Save/Load
_saveVersion and _loadVersion use structuredClone. This means:
- Functions stored as property values cannot be cloned (will throw).
- DOM nodes, WeakMaps, WeakSets, and other non-cloneable types will throw.
- Stick to plain data (primitives, objects, arrays, Maps, Sets, Dates, RegExps, etc.).
_version, _subscribe, _reset, _saveVersion, and _loadVersion do not appear in Object.keys(), for...in, JSON.stringify(), or spread operations:
const store = create(Counter);
Object.keys(store);
JSON.stringify(store);
9. run Re-Tracks on Every Execution
Each time run's callback executes, it re-tracks which stores are accessed. If your callback conditionally accesses stores, the tracked set may change between runs:
run(() => {
if (storeA.flag) {
console.log(storeB.value);
}
});
TypeScript
The library exports the following types:
import type { Store } from "immu-js";
import type { Computed } from "immu-js";
Store<T> — the store type, which is T augmented with reactive extras:
type Store<T> = T & {
readonly _version: number;
_subscribe: (callback: () => void, prop?: string) => () => void;
_reset: () => void;
_saveVersion: (label?: string) => void;
_loadVersion: (label?: string) => void;
};
Computed<T> — the computed value wrapper:
interface Computed<T> {
readonly value: T;
}
memo fully infers argument types and return types:
const fn = memo((x: number, y: string) => `${x}-${y}`);
Build & Test
npm run build
npm test
npm run test:watch
The test suite enforces 100% code coverage across statements, branches, functions, and lines.
License
MIT