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

v0.2.0

Published

DOM bindings for @evgkch/reactive: Struct, List, Slot, ReactiveElement

Downloads

194

Readme

@evgkch/reactive-dom

npm

DOM bindings for @evgkch/reactive. No VDOM, no compiler, no magic — two primitives, one context, close to the platform.

Philosophy

The library maps directly onto the browser. Templates are real HTML parsed once by the browser. Elements are real DOM nodes. Reactivity is explicit — you decide what updates and when.

Two primitives mirror the data layer:

R.Struct  →  UI.Struct   // one element
R.List    →  UI.List     // collection of elements

One context handles subscriptions. One function unmounts everything.


Install

npm install @evgkch/reactive @evgkch/reactive-dom

Peer: @evgkch/reactive ^1.1.0.

import * as R from "@evgkch/reactive";
import * as UI from "@evgkch/reactive-dom";

UI.Struct

Creates a reusable factory from an HTML template. The template is parsed once via <template>; each factory call clones it and runs setup(props, refs, ctx).

const UserCard = UI.Struct(
    `<div class="card">
        <span data-ref="name"></span>
        <span data-ref="age"></span>
    </div>`,
    (props: { user: User; theme: string }, refs, ctx) => {
        ctx.batch(() => {
            refs.name.textContent = props.user.name;
            refs.age.textContent = String(props.user.age);
            refs.el.className = `card ${props.theme}`;
        });
    },
);

const el = UserCard({ user, theme: "dark" });
document.body.appendChild(el);
  • refs.el — root element
  • refs[name] — element with [data-ref="name"]
  • ctx — component context, collects all subscriptions

ctx.batch runs synchronously on first call — the element is fully rendered before it enters the DOM. On subsequent reactive changes it re-runs automatically.

To remove an element and stop its subscriptions:

UI.remove(el);

UI.List

Same signature as UI.Struct, but the factory has two modes depending on how it is called.

const TaskItem = UI.List(
    `<li>
        <input type="checkbox" data-ref="check" />
        <span data-ref="text"></span>
        <button data-ref="remove">✕</button>
    </li>`,
    (props: { item: Task }, refs, ctx) => {
        ctx.batch(() => {
            refs.text.textContent = props.item.text;
            (refs.check as HTMLInputElement).checked = props.item.done;
            refs.el.className = props.item.done ? "done" : "";
        });
        refs.check.onchange = () => {
            props.item.done = (refs.check as HTMLInputElement).checked;
        };
        refs.remove.onclick = () => tasks.splice(tasks.indexOf(props.item), 1);
    },
);

Mode 1 — single element. Pass props, get an Element back. Same as UI.Struct:

const el = TaskItem({ item: task });
document.body.appendChild(el);

Mode 2 — mount list. Pass a container, a reactive source, and a props mapper. Returns a stop function:

const stop = TaskItem(ul, tasks, (item) => ({ item }), {
    onAdd: (el) => el.animate([{ opacity: 0 }, { opacity: 1 }], 150),
    onRemove: (el, done) => el.animate([{ opacity: 1 }, { opacity: 0 }], 150).finished.then(done),
});

In list mode the library uses Watch to track operations on the source:

  • push → one element created and appended
  • splice → affected elements unmounted and removed
  • sort / reverse → nodes reordered in place

onAdd fires only for elements added after initial mount — not for the initial list. Use it for enter animations, not for rendering.

onRemove receives done — the element is removed from the DOM only after done() is called, enabling exit animations.


Context

Third argument in every setup, after props and refs. Every subscription registered through ctx is automatically stopped when the component is unmounted.

ctx.batch(fn)                                               // reactive effect
ctx.watch(source, fn)                                       // operation handler (sync, with patch)
ctx.list(container, source, propsFactory, factory, hooks?)  // nested list
ctx.slot(container, getter)                                 // conditional render

There is no need to manage stop functions manually — ctx collects them all.


Composition

Nested list:

const ColumnItem = UI.Struct(
    `<div class="column">
        <h3 data-ref="title"></h3>
        <ul data-ref="tasks"></ul>
    </div>`,
    (props: { col: Column }, refs, ctx) => {
        ctx.batch(() => {
            refs.title.textContent = props.col.title;
        });
        ctx.list(refs.tasks, props.col.tasks, (item) => ({ item }), TaskItem);
    },
);

Conditional render:

const App = UI.Struct(`<div><div data-ref="panel"></div></div>`, (props, refs, ctx) => {
    ctx.slot(refs.panel, () => (user.isAdmin ? AdminPanel({ user }) : UserPanel({ user })));
    // when user.isAdmin changes — old element is unmounted, new one mounted
});

Multiple data sources in one component:

const Item = UI.Struct(`<div data-ref="el"></div>`, (props: { item: Task }, refs, ctx) => {
    ctx.batch(() => {
        refs.el.className = [
            props.item.done ? "done" : "",
            filter.get() === "active" && props.item.done ? "hidden" : "",
        ]
            .join(" ")
            .trim();
    });
});

UI.Slot

Conditional render outside a component:

const stop = UI.Slot(container, () => (isOpen.get() ? Modal({ title }) : null));

An anchor Comment node fixes the position in the DOM. When the getter result changes — the previous element is unmounted, the new one is inserted.


UI.remove / UI.unmount

UI.remove(el); // stop all subscriptions + el.remove()
UI.unmount(el); // stop all subscriptions, element stays in DOM

UI.List and UI.Slot call UI.remove automatically on their elements. For manually mounted elements call it explicitly.


Debug logging

Turn on component lifecycle logs to see mount, list add/remove, slot swap, and unmount. Uses a [reactive-dom] prefix; meta is the element at the call site — same pattern as @evgkch/reactive.

UI.configure({ log: true });
// [reactive-dom] [struct:mount] <element>
// [reactive-dom] [list:mount] <element>
// [reactive-dom] [list:add] <element>
// [reactive-dom] [struct:unmount] <element>
  • configure({ log: true }) — use the default console logger.
  • configure({ log: logger }) — your logger { log(message: string, meta?: unknown): void } (same shape as @evgkch/reactive).
  • configure({ log: null }) — off (default). No overhead: one guard, no work when disabled.

ReactiveElement

Base class for Custom Elements. Override mount(ctx) — it is called by the browser lifecycle at the right time.

class TaskItemEl extends UI.ReactiveElement {
    #props: { item: Task } | null = null;

    set props(v: { item: Task }) {
        this.#props = v;
        this.invalidate(); // re-mounts if already in DOM
    }

    protected mount(ctx: Context): void {
        if (!this.#props) return;
        const { item } = this.#props;
        ctx.batch(() => {
            this.textContent = item.text;
            this.className = item.done ? "done" : "";
        });
    }
}

customElements.define("task-item", TaskItemEl);

disconnectedCallback calls unmount automatically — UI.List needs no manual stop:

TaskItem(ul, tasks, (item) => {
    const el = document.createElement("task-item") as TaskItemEl;
    el.props = { item };
    return el; // no stop needed
});

Full example — process monitor

A compact terminal-style app: live CPU/MEM metrics, reactive process list, hotkeys.

import * as R from "@evgkch/reactive";
import * as UI from "@evgkch/reactive-dom";

const filter = R.Value("all");
const cpu = R.Value(42);
const tasks = R.List([
    R.Struct({ text: "infiltrate server", done: false }),
    R.Struct({ text: "extract data", done: true }),
]);

setInterval(() => {
    cpu.set(Math.max(10, Math.min(99, cpu.get() + Math.floor(Math.random() * 11) - 5)));
}, 1000);

const TaskItem = UI.List(
    `<li>
        <input type="checkbox" data-ref="check" />
        <span data-ref="text"></span>
        <span data-ref="status"></span>
        <button data-ref="remove">kill</button>
    </li>`,
    (props: { item: Task }, refs, ctx) => {
        ctx.batch(() => {
            refs.text.textContent = props.item.text;
            (refs.check as HTMLInputElement).checked = props.item.done;
            refs.el.className = props.item.done ? "done" : "";
            refs.status.textContent = props.item.done ? "DONE" : "RUNNING";

            const f = filter.get();
            refs.el.style.display =
                f === "active" ? (props.item.done ? "none" : "") : f === "done" ? (!props.item.done ? "none" : "") : "";
        });
        refs.check.onchange = () => {
            props.item.done = (refs.check as HTMLInputElement).checked;
        };
        refs.remove.onclick = () => tasks.splice(tasks.indexOf(props.item), 1);
    },
);

const App = UI.Struct(
    `<div class="monitor">
        <div class="cpu-bar" data-ref="cpu-bar"></div>
        <div class="prompt">
            <span>$</span>
            <input data-ref="input" placeholder="spawn process..." />
        </div>
        <ul data-ref="list"></ul>
        <div class="statusbar">
            <span data-ref="count"></span>
            <div data-ref="filters">
                <button data-filter="all">all</button>
                <button data-filter="active">running</button>
                <button data-filter="done">done</button>
            </div>
        </div>
    </div>`,
    (props, refs, ctx) => {
        ctx.list(refs.list, tasks, (item) => ({ item }), TaskItem, {
            onAdd: (el) => el.animate([{ opacity: 0 }, { opacity: 1 }], 150),
            onRemove: (el, done) => el.animate([{ opacity: 1 }, { opacity: 0 }], 130).finished.then(done),
        });

        ctx.batch(() => {
            refs["cpu-bar"].style.width = cpu.get() + "%";
        });

        ctx.batch(() => {
            let running = 0;
            tasks.forEach((t) => {
                if (!t.done) running++;
            });
            refs.count.textContent = `${running} of ${tasks.length} running`;
        });

        ctx.batch(() => {
            refs.filters.querySelectorAll("[data-filter]").forEach((btn) => {
                btn.className = filter.get() === btn.getAttribute("data-filter") ? "active" : "";
            });
        });

        refs.filters.addEventListener("click", (e) => {
            const f = (e.target as HTMLElement).getAttribute("data-filter");
            if (f) filter.set(f);
        });

        const input = refs.input as HTMLInputElement;
        input.addEventListener("keydown", (e) => {
            if (e.key !== "Enter" || !input.value.trim()) return;
            tasks.push(R.Struct({ text: input.value.trim(), done: false }));
            input.value = "";
        });

        document.addEventListener("keydown", (e) => {
            if (document.activeElement === input) return;
            if (e.key === "1") filter.set("all");
            if (e.key === "2") filter.set("active");
            if (e.key === "3") filter.set("done");
            if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) input.focus();
        });
    },
);

document.getElementById("app")!.appendChild(App({}));

API reference

| API | Description | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | Struct(html, setup) | Factory (props) => Element. Setup: (props, refs, ctx). Template parsed once. | | List(html, setup) | Dual factory. (props) => Element or (container, source, propsFactory, hooks?) => stop. onAdd fires only for items added after initial mount. | | Slot(container, getter) | Conditional slot outside a component. Swaps element on change. Returns stop. | | remove(el) | Stop all subscriptions + el.remove(). | | unmount(el) | Stop all subscriptions. Element stays in DOM. | | ReactiveElement | Base class for Custom Elements. Override mount(ctx), call invalidate() from setters. | | configure({ log? }) | true = console logger, { log(msg, meta?) } = your logger, null = off. Same pattern as @evgkch/reactive. |


Examples

Live: https://evgkch.github.io/reactive-dom/ — monitor, console, stream.

Local: npm run examples


Testing

npm run build && npm test

License

ISC