npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

immu-js

v0.5.0

Published

Independent immutable state library for JavaScript/TypeScript

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; // 0

Table of Contents


Install

npm install immu-js

ESM and CJS builds are both included:

import { create, memo, computed, run } from "immu-js"; // ESM
const { create, memo, computed, run } = require("immu-js"); // CJS

Quick 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 reacting

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; // ["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 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; // 0
store.incr();
store._version; // 1
store.incr();
store._version; // 2

Each 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 listening

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; // 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 _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; // "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; // 2

Named 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; // 3

Parameters:

| 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     → 2

Parameters:

| Param | Type | Description | |-------|------|-------------| | fn | (...args: TArgs) => TReturn | Function to memoize |

Returns: (...args: TArgs) => TReturn — memoized version of fn.

How caching works:

  1. Arguments are compared via JSON.stringify — if the serialized args string matches, args are considered equal.
  2. Each tracked store's _version is compared — if any store's version changed, the function recomputes.
  3. 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(); // cached

Multi-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 logged

Parameters:

| 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 effect

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] // only re-run when `a` changes
);

store.b = 99; // deps unchanged → callback does NOT re-run
store.a = 10; // deps changed   → callback re-runs

Deps 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-runs

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));
// 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 + 100

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;  // 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; // 1

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) {
    // ✅ 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; // 3

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();
// cb has NOT been called yet (async)

// after microtask flush → cb called exactly once

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));
// 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 error

Empty Class

Creating a store from a class with no properties works fine:

class Empty {}
const store = create(Empty);
store._version; // 0

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; // 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, 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.
// 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) => string

Build & Test

# Build (ESM + CJS + type declarations)
npm run build

# Run tests
npm test

# Run tests in watch mode
npm run test:watch

The test suite enforces 100% code coverage across statements, branches, functions, and lines.


License

MIT