immu-js
v0.5.0
Published
Independent immutable state library for JavaScript/TypeScript
Maintainers
Readme
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";
// 1. Define state as a plain class
class Counter {
count = 0;
address = {
street: "test",
}
// setter functions
incr() { this.count++; }
decr() { this.count--; }
setStreet(street: string){
// need to reassign the object to trigger the change
this.address = { ...this.address, street };
}
// getter functions
getStreet(){
// you can use functions to get values from the store
return this.address.street;
}
}
// 2. Create a reactive store
const store = create(Counter);
// 3. Derive cached values with memo (recomputes only when store or args change)
const doubled = memo((n: number) => store.count * n);
doubled(2); // 0
store.incr();
doubled(2); // 2 (recomputed — store changed)
doubled(2); // 2 (cached — nothing changed)
// 4. Derive cached values with computed (no args, access via .value)
const label = computed(() => `Count is ${store.count}`);
label.value; // "Count is 1"
store.incr();
label.value; // "Count is 2" (recomputed)
label.value; // "Count is 2" (cached)
// 5. React to changes with run (auto-tracks which stores you read)
const dispose = run(() => {
console.log("count changed:", store.count);
});
// logs immediately: "count changed: 2"
store.incr(); // after microtask → logs: "count changed: 3"
dispose(); // stop reacting
// 6. Subscribe to specific property changes
const unsub = store._subscribe(() => console.log("count!", store.count), "count");
store.incr(); // after microtask → logs: "count! 4"
unsub();
// 7. Snapshot & restore state
store._saveVersion("checkpoint");
store.incr();
store.incr();
store.count; // 6
store._loadVersion("checkpoint");
store.count; // 4
// 8. Reset to initial values
store._reset();
store.count; // 0Table of Contents
Install
npm install immu-jsESM and CJS builds are both included:
import { create, memo, computed, run } from "immu-js"; // ESM
const { create, memo, computed, run } = require("immu-js"); // CJSQuick Start
import { create, memo, run } from "immu-js";
class Counter {
count = 0;
incr() { this.count++; }
decr() { this.count--; }
}
const store = create(Counter);
// Reactive effect — runs immediately, then re-runs on change
const dispose = run(() => {
console.log("count:", store.count);
});
// logs: "count: 0"
store.incr();
// after microtask flush → logs: "count: 1"
// Memoized derived value
const doubled = memo((multiplier: number) => store.count * multiplier);
doubled(2); // 2 (computes)
doubled(2); // 2 (cached — same args, same store version)
store.incr();
doubled(2); // 4 (recomputes — store changed)
dispose(); // stop reactingAPI 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; // ["Buy milk"]Parameters:
| Param | Type | Description |
|-------|------|-------------|
| 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 inObject.keys(),JSON.stringify(), orfor...inloops.
store._version
A read-only integer that increments on every property set. Useful for cache invalidation.
const store = create(Counter);
store._version; // 0
store.incr();
store._version; // 1
store.incr();
store._version; // 2Each individual property assignment increments the version independently:
class Multi { a = 1; b = 2; }
const store = create(Multi);
store.a = 10; // _version → 1
store.b = 20; // _version → 2_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);
// Subscribe to ALL property changes
const unsub = store._subscribe(() => {
console.log("store changed, count is", store.count);
});
store.incr();
// after flush → "store changed, count is 1"
unsub(); // stop listeningProp-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; // after flush → "a changed!"
store.b = 20; // after flush → "b changed!"
// Changing `b` does NOT trigger the `a` subscriber, and vice versa.Parameters:
| Param | Type | Description |
|-------|------|-------------|
| 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; // 2
store._reset();
store.count; // 0- Goes through setters, so subscribers are notified and
_versionincrements. - 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; // "Delhi"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(); // saves under label "default"
store._saveVersion("v1"); // saves under label "v1"Parameters:
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| 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(); // count = 2
store.incr();
store.incr();
store.count; // 4
store._loadVersion(); // restores "default"
store.count; // 2Named versions:
store.incr();
store._saveVersion("v1"); // count = 1
store.incr();
store.incr();
store._saveVersion("v2"); // count = 3
store._loadVersion("v1");
store.count; // 1
store._loadVersion("v2");
store.count; // 3Parameters:
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| label | string | "default" | Label of the snapshot to restore |
Throws: Error if no snapshot exists for the given label.
store._loadVersion("nonexistent");
// Error: No saved version found for label "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); // computes → 0
doubled(2); // cached → 0 (same args, same store version)
doubled(3); // computes → 0 (different args)
store.incr();
doubled(2); // recomputes → 2 (store changed)
doubled(2); // cached → 2Parameters:
| Param | Type | Description |
|-------|------|-------------|
| 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
_versionis 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(); // computes
total(); // cachedMulti-argument memo:
const fmt = memo((a: number, b: string) => `${store.count * a}-${b}`);
fmt(2, "x"); // "0-x"Multiple stores:
const counter = create(Counter);
const users = create(UserStore);
const summary = memo(() => `${counter.count} users: ${users.users.join(", ")}`);
summary(); // "0 users: "
counter.incr();
summary(); // "1 users: "
users.addUser("Alice");
summary(); // "1 users: Alice"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; // 0 (computes)
doubled.value; // 0 (cached)
store.incr();
doubled.value; // 2 (recomputes)
doubled.value; // 2 (cached)Parameters:
| Param | Type | Description |
|-------|------|-------------|
| 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; // 3 (0 + 1 + 2)
storeA.incr();
combined.value; // 4 (1 + 1 + 2)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);
});
// immediately logs: "count: 0"
store.incr();
// after flush → "count: 1"
store.incr();
// after flush → "count: 2"
dispose(); // stops reacting
store.incr(); // nothing loggedParameters:
| Param | Type | Description |
|-------|------|-------------|
| 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);
// only `counter` is tracked — changes to `users` won't trigger this
});
users.addUser("Alice"); // does NOT re-trigger the effect
counter.incr(); // re-triggers the effectWith 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] // only re-run when `a` changes
);
store.b = 99; // deps unchanged → callback does NOT re-run
store.a = 10; // deps changed → callback re-runsDeps can track different stores than the callback:
const storeA = create(Counter);
const storeB = create(Counter);
run(
() => console.log("A:", storeA.count),
() => [storeB.count] // re-run is gated on storeB
);
storeB.incr(); // deps changed → callback re-runsBatching:
Multiple synchronous mutations are batched — the effect fires only once per flush:
const store = create(Counter);
const values: number[] = [];
run(() => values.push(store.count));
// values: [0]
store.incr();
store.incr();
store.incr();
// after flush → values: [0, 3] (NOT [0, 1, 2, 3])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(); // "Not logged in"
auth.login("Alice");
summary(); // "Alice's cart: (empty)"
cart.add("Book");
summary(); // "Alice's cart: Book"Nested Memo / Computed
memo and computed support nesting — inner dependencies bubble up to outer computations:
const store = create(Counter);
// Deeply nested chain
const level1 = memo(() => store.count * 2);
const level2 = memo(() => level1() + 10);
const level3 = memo(() => level2() + 100);
level3(); // 110 (0*2 + 10 + 100)
store.incr();
level3(); // 112 (1*2 + 10 + 100)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; // 102 → (0+1)*2 + 100
store.incr();
c2.value; // 104 → (1+1)*2 + 100Class 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; // 0
store.childCount; // 10
store.grandValue; // "hello"
store.incrBase();
store._version; // 1
// _reset restores ALL inherited properties
store._reset();
store.baseCount; // 0
// _saveVersion / _loadVersion includes inherited properties
store.incrBase();
store._saveVersion("snap");
store.incrBase();
store._loadVersion("snap");
store.baseCount; // 1Immutable 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) {
// ✅ Correct — creates a new object, triggers setter
this.address = { ...this.address, city };
}
}
class UserStore {
users: string[] = [];
addUser(user: string) {
// ✅ Correct — creates a new array, triggers setter
this.users = [...this.users, user];
}
}Snapshot / Time-Travel
Use _saveVersion and _loadVersion for undo/redo or checkpoint patterns:
const store = create(Counter);
store.incr(); // count = 1
store._saveVersion("step1");
store.incr(); // count = 2
store._saveVersion("step2");
store.incr(); // count = 3
store._saveVersion("step3");
// Undo to step1
store._loadVersion("step1");
store.count; // 1
// Redo to step3
store._loadVersion("step3");
store.count; // 3Edge 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();
// cb has NOT been called yet (async)
// after microtask flush → cb called exactly onceDispose 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));
// values: [0]
store.incr();
dispose(); // dispose before flush
// after flush → values is still [0]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()); // disposes the run effect
dispose = run(() => {
console.log(store.count);
});
store.incr();
// after flush → the run effect does NOT re-execute (disposed by the other subscriber)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(); // no errorEmpty Class
Creating a store from a class with no properties works fine:
class Empty {}
const store = create(Empty);
store._version; // 0Symbol 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; // 0 (reactive)
(store as any)[sym]; // "symbol-value" (not reactive)Overwriting Saved Versions
Saving with the same label overwrites the previous snapshot:
const store = create(Counter);
store.incr();
store._saveVersion("x"); // saves count = 1
store.incr();
store.incr();
store._saveVersion("x"); // overwrites → saves count = 3
store._loadVersion("x");
store.count; // 3 (not 1)Loading Non-Existent Versions
Throws an error with a descriptive message:
store._loadVersion("nonexistent");
// Error: No saved version found for label "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; // "Mumbai" (not "Kolkata")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++; }; // arrow (instance property)
getCount = () => { return this.count; }; // arrow
}
class ProtoStore {
count = 0;
incr() { this.count++; } // prototype method
}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();
// store.count is already updated here ✅
// but subscribers have NOT been called yet ❌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:
// ❌ WRONG — mutating in place, setter never fires
store.address.city = "Mumbai";
// ✅ CORRECT — assign a new object to trigger the setter
store.address = { ...store.address, city: "Mumbai" };The same applies to arrays:
// ❌ WRONG — push mutates in place
store.users.push("Alice");
// ✅ CORRECT — spread into a new array
store.users = [...store.users, "Alice"];3. memo Uses JSON.stringify for Argument Comparison
Arguments are serialized with JSON.stringify. This means:
- Functions,
undefined, andSymbolvalues are dropped or converted tonullduring 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.
// These are treated as different args even though semantically equal:
memo((obj) => obj.x)({ x: 1 }); // computes
memo((obj) => obj.x)({ x: 1 }); // computes again (different object reference → different JSON)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); // computes
doubled(3); // computes (different args, evicts cache)
doubled(2); // computes again (cache was for args=3)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:
// ❌ Won't work
class Store {
constructor(public initialCount: number) {}
}
// ✅ Use default values instead
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.).
8. Store Extras Are Non-Enumerable
_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); // ["count"] — no _version, _subscribe, etc.
JSON.stringify(store); // '{"count":0}'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); // storeB only tracked when flag is true
}
});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}`);
// fn: (x: number, y: string) => stringBuild & Test
# Build (ESM + CJS + type declarations)
npm run build
# Run tests
npm test
# Run tests in watch mode
npm run test:watchThe test suite enforces 100% code coverage across statements, branches, functions, and lines.
License
MIT
