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

@evgkch/reactive

v2.2.0

Published

Minimal reactive primitives — Value, Struct, List, Batch, Watch, built on Reactive and Subscriber

Readme

@evgkch/reactive

npm

Minimal reactive primitives for JavaScript and TypeScript.

Philosophy

Reactivity is explicit. You decide what is reactive. You decide what to track. No magic, no surprises.

This library does the opposite of most reactive systems — it does not auto-wrap nested objects, does not guess what you want to track, and does not hide complexity behind abstractions.

Three primitives for state:

  • Value — a single reactive value
  • Struct — a reactive object, tracked per property
  • List — a reactive array

Two ways to react:

  • Batch — reruns when state changes (batched, async)
  • Watch — fires when an operation happens (sync, with patch)

Two base classes for building your own:

  • Reactive — base for custom reactive primitives
  • Subscriber — base for custom subscribers

Everything in the library is built on these five primitives and two base classes. They are all public — you can extend any of them to build your own reactive primitives that work seamlessly with Batch and Watch.

Architecture

Context  (@evgkch/context)
│
├── Reactive<D>            deps · observe · emit · detach
│   ├── ReactiveValue<T>   — internal, bound to Value()
│   ├── ReactiveStruct<T>  — internal, bound to proxy via WeakMap
│   └── ReactiveList<T>    — internal, bound to proxy via WeakMap
│
└── Subscriber             sources · receive · close
    ├── Batcher
    └── Watcher<D>

Context carries the execution stack and ownership tree — close the parent, close everything below it.

Reactive adds a dependency map — primitives know who reads them and notify on change.

Subscriber adds a source set — effects know what they read and detach on close.

Batcher sets itself as current context when it runs. Any Reactive read during execution registers it as a subscriber and reruns it on change.

Struct and List return plain proxies (T and T[]). Their internal Reactive is bound via WeakMapWatch finds it automatically.

Install

npm install @evgkch/reactive

Quick start

import { Value, Struct, List, Batch, Watch, Untrack } from "@evgkch/reactive";

const count = Value(0);

Batch(() => {
    // tracked read — Batch reruns when count changes
    const current = count.get();

    // untracked read — does NOT register a dependency
    const snapshot = Untrack(() => count.get());

    console.log("count:", current, "snapshot:", snapshot);
});

count.set(1); // → logs with updated values (next microtask)
count.update((n) => n + 1); // → logs again (next microtask)

Primitives

Value

const score = Value(0);

score.set(10);
score.update((n) => n * 2); // → 20

Batch(() => console.log("score:", score.get()));

Struct

Returns a plain proxy — use it like a normal object. TypeScript sees the original type.

const user = Struct({ name: "alice", age: 25 });

Batch(() => console.log("name:", user.name));

user.name = "bob"; // → Batch runs (next microtask)
user.age = 30; // → Batch does NOT run (nobody reads age)

List

Returns a plain array proxy — use it like a normal array. TypeScript sees T[].

const tasks = List(["buy milk", "write code"]);

Batch(() => console.log(tasks.map((t) => t.toUpperCase())));

tasks.push("ship it"); // → Batch runs (next microtask)
tasks.sort(); // → Batch runs (next microtask)

Batch

Runs a function immediately, then reruns it when any reactive value it read has changed. Updates are batched in a microtask — multiple changes in one tick produce one rerun. Returns a stop function.

const a = Value(1);
const b = Value(2);

const stop = Batch(() => {
    console.log("sum:", a.get() + b.get());
});
// → sum: 3

a.set(10);
b.set(20);
// one microtask later → sum: 30 (not twice)

stop();
a.set(99); // → silence

Watch

Fires synchronously when an operation happens. Receives patch data describing exactly what changed.

const list = List([1, 2, 3]);

const stop = Watch(list, (patch) => {
    console.log("added:", patch.added, "removed:", patch.removed);
});

list.push(4); // → added: [4] removed: []
list.splice(0, 1); // → added: [] removed: [1]

stop();

Works on Value and Struct too:

Watch(user, (patch) => {
    console.log(`${String(patch.key)}: ${patch.prev} → ${patch.next}`);
});

user.name = "carol"; // → name: bob → carol

Watch accepts any reactive primitive — Value, Struct, or List. For Struct and List it finds the internal reactive via WeakMap automatically.

Passing a non-reactive object throws:

Watch({ name: "alice" }, fn);
// → Error: Watch: source is not a reactive primitive

Method form is available on List:

tasks.watch(({ start, removed, added, reorder }) => { ... })

Batch vs Watch

| | Batch | Watch | | -------- | ----------------- | ----------------------- | | Timing | async (microtask) | sync | | Batching | yes | no | | Receives | — | patch | | Use for | state → view | operation → side effect |

Composition

const state = Struct({
    filter: Value("all"),
    items: List([Struct({ text: "Learn reactive", done: false }), Struct({ text: "Build app", done: true })]),
});

Batch(() => {
    const f = state.filter.get();
    const filtered = state.items.filter((item) => {
        if (f === "active") return !item.done;
        if (f === "completed") return item.done;
        return true;
    });
    render(filtered);
});

state.filter.set("active"); // → rerenders next microtask
state.items[0].done = true; // → rerenders next microtask

Lifecycle

Batch and Watch created inside another Batch are owned by the outer Batch:

  • they are automatically stopped when the outer Batch stops;
  • they are torn down and recreated on each outer Batch rerun (no accumulation across runs).
const stop = Batch(() => {
    value.get();

    // both effects belong to the outer Batch:
    // - they can react to their own dependencies between reruns
    // - they are recreated on each outer rerun
    // - they are stopped when `stop()` is called
    Watch(list, patch => { ... })
    Batch(() => { ... })
})

stop()  // → everything cleaned up

Debug logging

The library does not implement a logger. You pass your own — it is called with a message and optional metadata.

import { Batch, Watch, type ReactiveLogger } from "@evgkch/reactive";

const logger: ReactiveLogger = {
    log(message, meta) {
        console.log(message, meta ?? "");
    },
};

Batch.logger = logger;
Watch.logger = logger;

Detach by setting to null:

Batch.logger = null;
Watch.logger = null;

Custom primitives

Reactive and Subscriber are the base classes that power Value, Struct, and List. You can extend them to build your own reactive primitives that work seamlessly with Batch and Watch.

import { Reactive, Watcher } from "@evgkch/reactive";

class Clock extends Reactive<{ prev: number; next: number }> {
    static #KEY = Symbol("tick");
    #value = 0;

    get(): number {
        this.observe(Clock.#KEY);
        return this.#value;
    }

    tick(): void {
        const prev = this.#value;
        this.#value++;
        this.emit(Clock.#KEY, { prev, next: this.#value });
    }

    watch(fn: (data: { prev: number; next: number }) => void): () => void {
        const w = new Watcher(fn);
        this.observe(Clock.#KEY, w);
        return () => w.close();
    }
}

const clock = new Clock();

Batch(() => console.log("tick:", clock.get()));
Watch(clock, ({ prev, next }) => console.log(`${prev} → ${next}`));

clock.tick(); // → 0 → 1 (sync), tick: 1 (microtask)
clock.tick(); // → 1 → 2 (sync), tick: 2 (microtask)

API

| | | | ------------------- | ------------------------------------------------------------------------------ | | Value(initial) | Reactive cell. .get(), .set(v), .update(fn) | | Struct(data) | Reactive object proxy. Read/write properties as usual | | List(initial?) | Reactive array proxy. Full array API. .watch(fn) | | Batch(fn) | Runs fn reactively. Returns () => void to stop | | Untrack(fn) | Runs fn outside of any subscriber. Reads inside do not register dependencies | | Watch(source, fn) | Attach a watcher to any primitive. Returns () => void to stop | | Reactive<D> | Base class for custom reactive primitives | | Subscriber | Base class for custom subscribers | | Watcher<D> | Ready-to-use subscriber for Watch-style callbacks | | ReactiveLogger | { log(message: string, meta?: unknown): void } |

License

ISC