@evgkch/reactive-dom
v0.2.0
Published
DOM bindings for @evgkch/reactive: Struct, List, Slot, ReactiveElement
Downloads
194
Readme
@evgkch/reactive-dom
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 elementsOne context handles subscriptions. One function unmounts everything.
Install
npm install @evgkch/reactive @evgkch/reactive-domPeer: @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 elementrefs[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 appendedsplice→ affected elements unmounted and removedsort/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 renderThere 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 DOMUI.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 testLicense
ISC
