@zakkster/lite-element
v1.1.0
Published
Zero-GC reactive Custom Elements on @zakkster/lite-signal. No virtual DOM, no templating. State survives synchronous reparents (sort, drag-and-drop, insertBefore) -- the moves that destroy React/Vue/Lit. 1.1 adds reactive observed attributes, typed proper
Maintainers
Keywords
Readme
@zakkster/lite-element
Zero-GC reactive Custom Elements built on
@zakkster/lite-signal. No virtual DOM. State survives synchronous reparents -- the move operations that destroy React, Vue, and Lit components are no-ops here.
npm i @zakkster/lite-element @zakkster/lite-signalimport { define } from "@zakkster/lite-element";
define("counter-el", (host, { state, bind, on }) => {
const n = state(0);
host.innerHTML = `<button>+</button><span></span>`;
const [btn, out] = host.children;
on(btn, "click", () => n.update(v => v + 1));
bind(out, "textContent", () => `Count: ${n()}`);
});Headline (measured on Node 23, see Benchmarks): at steady
state, ~6 million bind writes per second. Component lifecycle: ~340K
mount+unmount/sec, ~3 B retained per cycle. A synchronous reparent (sort,
drag-and-drop, insertBefore) is ~13× faster than the React/Vue/Lit
destroy+rebuild path and allocates ~1.8 KB less per move.
Table of contents
- Why this exists
- What you get
- The reparent story
- Scope API
mountanddefine- Patterns
- vs React / Vue / Lit
- Benchmarks
- Edge cases pinned down
- What this is not
- Browser / runtime support
- FAQ
- License
Why this exists
A small set of design constraints picked deliberately:
- One concept. Setup runs once. You build the DOM yourself or query it.
Every binding is a real lite-signal
effectwriting into a real node. There is no VDOM, no template compiler, no JSX runtime, no hydration phase. - Zero-GC steady state. Mounting N reactive nodes allocates
nodes.push(handle)once per node -- flat array push, no per-node closure. Steady-statebindwrites hit lite-signal'sObject.iscutoff and re-write nothing when the result is unchanged. - Survives moves. When the browser fires
disconnectedCallbackimmediately followed byconnectedCallback-- the actual shape of a sort, a drag-and-drop, or aninsertBefore-- the scope is not torn down and rebuilt. Component state is preserved. (Details: The reparent story.) - Pool-clean teardown. On real disconnect, every node the scope created
is disposed, every listener removed, every
onCleanupruns.stats().activeNodesreturns to baseline. We test this with a 1000-cycle audit.
If you want a router, an SSR runtime, a templating language, slots, or a marketplace of UI primitives -- this is the wrong library. Three exports in 138 lines.
What you get
import { mount, define } from "@zakkster/lite-element";mount(host, setup) -> unmount-- drop a reactive scope on any node. Returns a synchronous unmount. Framework-free; ideal for islands and portals.define(name, setup, options?) -> Class-- register a reactive Custom Element. Samesetup(host, scope)shape asmount, with the reparent gate wired in.- The scope. Every method registers a teardown automatically:
state(value, opts?)-computed(fn, opts?)-effect(fn)bind(el, prop, fn)-attr(el, name, fn)-show(el, fn)on(el, type, handler, opts?)-onCleanup(fn)batch(fn)-- re-export of lite-signal's batch
That's the whole surface.
The reparent story
This is the differentiator. Pay attention to the timing.
What the browser actually does on a sort
When you do this:
parentB.insertBefore(elementFromParentA, parentB.firstChild);...the browser fires, synchronously, in the same tick:
elementFromParentA.disconnectedCallback()(from parentA)elementFromParentA.connectedCallback()(now in parentB)
A naive Custom Element implementation tears down on (1) and rebuilds on (2). Every signal is destroyed, every effect re-created, every listener re-attached, every internal counter reset to its initial value. React and Vue exhibit the same destruction pattern when a keyed component changes parents in a way their reconciler cannot trace.
What lite-element does
sequenceDiagram
participant Browser
participant LiteEl as lite-element
participant Scope
Note over Browser,Scope: Synchronous reparent (insertBefore)
Browser->>LiteEl: disconnectedCallback()
LiteEl->>LiteEl: queueMicrotask(teardownCheck)
Browser->>LiteEl: connectedCallback()
LiteEl->>LiteEl: __unmount still set -> no-op
Note over Scope: state preserved
Note over Browser,LiteEl: --- next microtask ---
LiteEl->>LiteEl: teardownCheck: isConnected?
Note over LiteEl: true -> keep scope alive
Note over Browser,Scope: Real disconnect (removeChild, never reattached)
Browser->>LiteEl: disconnectedCallback()
LiteEl->>LiteEl: queueMicrotask(teardownCheck)
Note over Browser,LiteEl: --- next microtask ---
LiteEl->>LiteEl: teardownCheck: isConnected?
Note over LiteEl: false -> scope.dispose()
LiteEl->>Scope: dispose all nodes + cleanupsThe whole mechanism is six lines:
disconnectedCallback() {
queueMicrotask(() => {
if (!this.isConnected && this.__unmount) {
this.__unmount();
this.__unmount = null;
}
});
}queueMicrotask (not setTimeout) means a genuine disconnect tears down at
the end of the current task -- fast enough that nothing keeps the event loop
alive, slow enough that a synchronous reparent slips under the gate.
Why this matters
Sorting a list of 1,000 stateful items used to be:
- React keyed list with a
key={item.id}: in many cases, fine -- but only when React's reconciler can trace the key. Cross-parent moves and dynamic containers regularly invalidate this and rebuild. - Vue
<TransitionGroup>: same caveat. - Lit: each
LitElementrunsdisconnectedCallback+connectedCallback, tearing down itsReactiveControllerchain on every move.
Sorting a list of 1,000 lite-element items: the browser moves the nodes
and lite-element does literally nothing. Internal state -- counters, scroll
positions, expanded/collapsed flags, the <input> value inside the card --
all of it survives. No rebuild cost, no rebind cost, no flicker.
Scope API
Every method on the scope creates a primitive that is disposed on unmount.
The only way to leak is to allocate something outside the scope and forget
to register it with onCleanup.
state(value, opts?)
Component-scoped writable signal. Identical to lite-signal's signal -- same
peek, set, update, subscribe -- but the underlying handle is reclaimed
when the scope tears down.
const count = state(0);
count(); // read (tracked)
count.peek(); // read (untracked)
count.set(5);
count.update(v => v + 1);computed(fn, opts?)
Derived value. Lazy, memoised, dirty-tracked. Same as lite-signal's computed.
const dbl = computed(() => count() * 2);
const sum = computed(() => a() + b());effect(fn)
A side effect that runs once now and again on every dep change. Disposed on
unmount. Use for anything that doesn't fit bind/attr/show (logging,
sending analytics, etc).
bind(el, prop, fn)
The workhorse. Writes el[prop] = fn() whenever a dep changes. Reading
the same value writes nothing because lite-signal's Object.is cutoff stops
propagation upstream.
bind(out, "textContent", () => `Count: ${n()}`);
bind(input, "value", () => model());
bind(progress, "style.transform", () => `translateX(${pos()}px)`); // string props onlyattr(el, name, fn)
Set an HTML attribute reactively. The value is coerced by type:
| Returned value | Effect |
|--------------------|---------------------------------|
| null / undefined / false | removeAttribute(name) |
| true | setAttribute(name, "") |
| anything else | setAttribute(name, String(v)) |
attr(btn, "disabled", () => loading());
attr(li, "aria-selected", () => selected());
attr(card, "data-state", () => mode());show(el, fn)
Reactive el.hidden = !fn(). Shorthand for the common visibility toggle.
on(el, type, handler, opts?)
Add an event listener; auto-removed on unmount. Returns an early-removal function -- call it to detach immediately.
on(btn, "click", () => count.update(v => v + 1));
const off = on(input, "keydown", handleKey);
// ...later:
off(); // detach before unmountonCleanup(fn)
Register arbitrary teardown -- timers, observers, third-party subscriptions. Cleanups run in reverse insertion order; a throwing cleanup does not stop the rest from firing.
onCleanup(() => clearInterval(pollId));
onCleanup(() => observer.disconnect());batch(fn)
Re-export of lite-signal's batch. Multiple .set() calls inside coalesce
into one propagation.
batch(() => {
firstName.set("Zahary");
lastName.set("Shinikchiev");
});
// All bound effects re-run exactly once.useAttr(name) (1.1)
Reactive read of an observed attribute. Returns a getter that tracks the
attribute's value (string or null) and updates on attributeChangedCallback.
Only attributes listed in the element's observedAttributes are reactive.
define("user-badge", (host, { useAttr, bind }) => {
const name = useAttr("name");
bind(host, "textContent", () => name() ?? "anonymous");
}, { observedAttributes: ["name"] });
// <user-badge name="Zahary"></user-badge> -> re-renders when `name` changesprop(name, initial, options?) (1.1)
Declares a reactive property backed by a disposed-on-unmount signal, with a
host[name] getter/setter. Options: type (Number / Boolean / String
coercion for the attribute), attribute (mirror name, defaults to the property
name), and reflect (write the value back to the attribute on change). When the
attribute is observed, external attribute changes flow into the property too --
and the Object.is cutoff keeps the reflect <-> observe pair from looping.
define("x-stepper", (host, { prop, bind }) => {
const value = prop("value", 0, { type: Number, reflect: true });
bind(host, "textContent", () => String(value()));
}, { observedAttributes: ["value"] });
// el.value = 5; // -> attribute "value" becomes "5", text re-renders
// el.setAttribute("value", "9"); // -> el.value === 9root, hydrating, internals (1.1)
root-- the rendering root: the shadow root when the element was defined with{ shadow }, otherwise the host. Build your DOM into this.hydrating--truewhen the host already had server-rendered content at connect time (light children, or a declarative-shadow-DOM root). Skip yourinnerHTMLassignment and bind to the existing nodes.internals-- theElementInternalswhen defined with{ formAssociated }, elsenull. Useinternals.setFormValue(v),setValidity(...), etc.
Lifecycle hooks (1.1)
onAdopted(cb), onFormReset(cb), onFormDisabled(cb), onFormStateRestore(cb)
register callbacks for the matching Custom Element lifecycle. They are disposed
with the scope and only fire on elements created via {@link define}.
mount and define
These are the same function with different lifecycle binding.
mount(host, setup) -> unmount
Framework-free. Pass any node, get a synchronous unmount.
const unmount = mount(document.getElementById("widget"), (host, { state, bind, on }) => {
const n = state(0);
bind(host, "textContent", () => `Count: ${n()}`);
on(host, "click", () => n.update(v => v + 1));
});
// Later, when the widget should die:
unmount();Calling unmount twice is safe. Mounting twice on the same host stacks two
scopes; each unmount disposes one.
define(name, setup, options?) -> CustomElementConstructor
Registers a <custom-tag> element. Setup runs on first connect; teardown
is gated on the microtask check above.
define("price-tag", (host, { state, attr, bind, on, onCleanup }) => {
const price = state(0);
const currency = state("USD");
bind(host, "textContent", () => `${currency()} ${price().toFixed(2)}`);
attr(host, "data-price", () => price());
// External refresh source -- track its teardown explicitly.
const sub = priceFeed.subscribe(host.dataset.symbol, p => price.set(p));
onCleanup(() => sub.unsubscribe());
});The optional third argument is a config object (1.1):
| field | type | effect |
|---|---|---|
| observedAttributes | string[] | attributes that become reactive via useAttr / prop |
| formAssociated | boolean | attaches ElementInternals (scope.internals) + form lifecycle callbacks |
| shadow | "open" \| "closed" | attaches (or adopts an existing declarative) shadow root, exposed as scope.root |
| extends | string | customized built-in tag, forwarded to customElements.define |
Server rendering and hydration (1.1)
lite-element components are imperative setup functions, so server rendering runs
setup against a server DOM and serializes the result; the client adopts that
markup instead of rebuilding it.
import { renderToString, toDeclarativeShadow } from "@zakkster/lite-element";
// --- server: build a host from any server DOM (happy-dom, linkedom, jsdom) ---
const html = renderToString(serverDoc.createElement("x-card"), cardSetup);
// or emit an encapsulated shadow root as declarative shadow DOM:
const dsd = renderToString(serverDoc.createElement("x-card"), cardSetup, { shadow: "open" });
// `dsd` is `<template shadowrootmode="open">...</template>` -- drop it inside <x-card>.
// --- client: define the same component; on connect scope.hydrating is true ---
define("x-card", (host, scope) => {
if (!scope.hydrating) scope.root.innerHTML = `<h3></h3><p></p>`; // create only if fresh
const [title] = scope.root.children;
scope.bind(title, "textContent", () => titleSig()); // bind either way
}, { shadow: "open" });renderToString runs setup once and disposes the scope immediately (no leak).
toDeclarativeShadow(innerHTML, mode?) is the bare string wrapper if you build the
markup yourself.
Patterns
Counter
define("counter-el", (host, { state, bind, on }) => {
const n = state(0);
host.innerHTML = `<button>+</button><span></span>`;
const [btn, out] = host.children;
on(btn, "click", () => n.update(v => v + 1));
bind(out, "textContent", () => `Count: ${n()}`);
});Form input with two-way binding
define("text-field", (host, { state, bind, on }) => {
const value = state("");
host.innerHTML = `<input type="text">`;
const input = host.firstChild;
bind(input, "value", () => value());
on(input, "input", e => value.set(e.target.value));
});Form-associated control (1.1)
A custom element that participates in a real <form> -- its value is submitted,
and it resets and restores with the form.
define("rating-input", (host, { state, internals, bind, on, onFormReset }) => {
const score = state(0);
host.innerHTML = `<button type="button">-</button><output></output><button type="button">+</button>`;
const [dec, out, inc] = host.children;
const commit = () => internals.setFormValue(String(score())); // submitted with the form
bind(out, "textContent", () => score());
on(dec, "click", () => { score.update(v => Math.max(0, v - 1)); commit(); });
on(inc, "click", () => { score.update(v => v + 1); commit(); });
commit();
onFormReset(() => { score.set(0); commit(); }); // form.reset() clears it
}, { formAssociated: true });
// <form><rating-input name="rating"></rating-input></form> -> submits rating=<score>A list that survives reorder
define("sortable-list", (host, { state, effect, on }) => {
const items = state([{ id: 1, text: "alpha" }, { id: 2, text: "beta" }]);
effect(() => {
// Crude: re-render the wrapper, but per-item state lives in the cards.
host.innerHTML = items().map(i => `<sortable-card data-id="${i.id}">${i.text}</sortable-card>`).join("");
});
on(host, "shuffle", () => {
items.update(arr => [...arr].sort(() => Math.random() - 0.5));
});
});
define("sortable-card", (host, { state, bind, on }) => {
// Each card has its OWN click count. Sort the parent -- counts stay put.
const clicks = state(0);
on(host, "click", () => clicks.update(v => v + 1));
bind(host, "title", () => `clicked ${clicks()} times`);
});Island on a server-rendered page
mount(document.getElementById("hero-cta"), (host, { state, on, bind }) => {
const visits = state(parseInt(localStorage.visits || "0", 10));
on(host, "click", () => {
visits.update(v => v + 1);
localStorage.visits = visits.peek();
});
bind(host.querySelector(".count"), "textContent", () => visits());
});External subscription with cleanup
define("clock-el", (host, { state, bind, onCleanup }) => {
const tick = state(Date.now());
const id = setInterval(() => tick.set(Date.now()), 1000);
onCleanup(() => clearInterval(id));
bind(host, "textContent", () => new Date(tick()).toLocaleTimeString());
});vs React / Vue / Lit
| | lite-element | React | Vue | Lit |
|-----------------------------------|:------------:|:-----:|:---:|:---:|
| Virtual DOM | none | yes | yes | partial |
| Templating language | none (you write HTML) | JSX | template DSL | tagged templates |
| Fine-grained reactivity | yes (lite-signal) | no (re-runs render) | yes | partial (lit-html) |
| Sync disconnect -> connect preserves state | yes | no rebuilds | no rebuilds | no rebuilds |
| Bundle size | ~3 KB + peer | ~45 KB | ~35 KB | ~6 KB |
| Lifecycle hooks | one (setup) | many | many | many |
| You write DOM directly | yes | through JSX | through templates | through templates |
You give up: a templating language, declarative children, hot ecosystem plugins, dev-tools that draw component trees, an opinionated form library.
You get: components that survive sort, drag, and re-parent. A 3 KB engine. No
mental model beyond "setup runs once, effect writes to the DOM".
Benchmarks
Measured on Node 22.22 with --expose-gc. Run yourself: npm run bench.
| Scenario | N | ops/sec | transient/op | retained/op |
|-------------------------------------------------------|-------:|----------:|-------------:|------------:|
| A) mount+unmount, small (1 sig, 1 bind, 1 on) | 20K | ~340K | ~420 B | ~3 B |
| B) mount+unmount, medium (2 sig + 2 comp + 2 bind + attr + 2 on) | 10K | ~190K | ~660 B | ~0 B |
| C) bind throughput, single cell (signal.set -> DOM) | 200K | ~6.2M | ~8 B | 0 B |
| D) bind throughput, 100 cells × 1 shared signal | 20K | ~95K | ~30 B | 0 B |
| E) reparent (lite-element survives, zero work) | 50K | ~1.97M| ~265 B | ~265 B |
| F) rebuild (React/Vue/Lit-style destroy+remount) | 5K | ~143K | ~2.1 KB | ~14 B |
Headline:
- bind writes hit ~6 million/sec on a single reactive cell -- that's lite-signal's effect-propagation cost; lite-element adds nothing measurable on top.
- a reparent is ~13× faster than a rebuild (the bench prints the exact ratio each run; we see 7-15× across hardware and Node versions) and allocates ~1.8 KB less per move. At 60 fps with 100 moving items per frame, that's the difference between a smooth frame and a dropped one.
- mount+unmount retains ~3-5 bytes per cycle.
stats().activeNodesreturns to 0 after the full benchmark -- verified by the post-bench audit and locked in bytest/zero-gc.test.js's 1000-cycle leak guard.
Numbers vary ~15% run-to-run with GC timing. The bench file is
bench/bench.mjs; copy it, modify, re-run.
Edge cases pinned down
unmount()is idempotent. Calling it twice is safe.- Mounting twice on the same host stacks two independent scopes. Each
unmountdisposes one. Lastbindwins for any contested property. - A throwing
onCleanupdoes not block the rest. Teardown continues; the error is swallowed silently (we are tearing down -- there's no caller to rethrow to). - Nodes are disposed before cleanups. Effects unsubscribe from their deps before listeners detach, so an effect cannot fire on a half-disposed scope.
- Effects are torn down in reverse registration order. The last
bindyou wrote is the first to stop writing. - Setup runs
untrack'd. Reading a signal insidesetupdoes NOT make setup itself reactive. To wire reactivity, useeffect/bind/attr/show. - The reparent gate is exactly one microtask. Anything that fires
disconnect -> connectwithin the current task is preserved. Anything that yields (setTimeout,await, idle callback) and then reconnects gets a fresh scope. - Custom Element
optionsare forwarded verbatim tocustomElements.define--{ extends: "button" }works as expected.
What this is not
- Not a templating library.
host.innerHTML = "<button></button>"is the expected idiom. If you want JSX, use a JSX runtime; lite-element doesn't care. - SSR is render-then-serialize, not a framework.
renderToStringruns setup against a server DOM you supply and serializes the output; the client adopts that markup viascope.hydrating. There is no streaming or build-step integration. - Shadow DOM is opt-in, not an abstraction.
{ shadow }attaches (or adopts a declarative) shadow root exposed asscope.root; lite-element does not provide styling, slotting, or part/theme helpers. - Form association is wiring, not semantics.
{ formAssociated }gives youscope.internalsand the form lifecycle callbacks; validation UI and submission semantics remain the platform's. - Not a routing library, a form library, or a data-fetching library. Bring whatever you like.
- Not a virtual DOM. Bindings write to real nodes immediately. There is no reconcile pass.
Browser / runtime support
| Runtime | Notes |
|-----------------|-------|
| Chrome 67+ | full |
| Firefox 63+ | full |
| Safari 13+ | full |
| Edge 79+ | full |
| Node 18+ | full (Custom Elements need a stub; mount() works directly) |
| Deno / Bun | full (same as Node) |
Requires customElements, queueMicrotask, ES2017+ for async, Object.is,
and module syntax. No polyfills shipped.
FAQ
Why one microtask and not zero / two / setTimeout?
- Zero is what naive Custom Elements do: they tear down on
disconnect. Reparent fails. setTimeout(0)keeps the event loop alive long enough that a Node process that mounts and disconnects an element will hang.queueMicrotaskdoesn't.- Two microtasks would let
await-yielding code re-attach. That's a different guarantee (and not a useful one); we want the in-task reparent only.
Why no props mechanism?
HTML attributes are the props mechanism. Read them in setup via
host.getAttribute(...). If you want them reactive, observe them with a
MutationObserver and pipe into a state() -- six lines.
Can I use this without lite-signal? No. The reactivity primitive is lite-signal -- it's the peer dependency. Bring your own DOM library; you can't bring your own signals.
Does bind work for non-string properties?
Yes -- anything you can assign to el[prop]. style.transform (string),
disabled (boolean), value (string), data-* (use attr()).
What about lists with key tracking?
The library doesn't ship a <For> primitive. Render your list however you
want. The reparent guarantee means a sort of N items costs 0 in lite-element
overhead -- the browser does the DOM moves, lite-element does nothing.
Custom Elements in SSR? The library doesn't render on the server. The custom-element class is registered in the browser when the module loads.
Why are the type definitions so loose around bind?
The strict overload constrains K extends keyof E, so bind(input, "value", ...)
infers string. The fallback overload accepts an arbitrary string for cases
where TS doesn't know the element type. The runtime is identical.
Is Element.js really only 138 lines?
Yes. Including blank lines and comments.
Files
Element.js-- the library (single file, 138 lines)Element.d.ts-- TypeScript definitionsREADME.md-- this filellms.txt-- machine-readable spec for LLM agentsLICENSE.txt-- MITtest/*.test.js-- 35-test suite (npm test)bench/bench.mjs-- six-scenario benchmark (npm run bench)demo/index.html-- interactive four-scene demo
Peer dependency
This package requires @zakkster/lite-signal ^1.1.3 to be installed alongside.
License
MIT (c) Zahary Shinikchiev
Part of the @zakkster/lite-* ecosystem: zero-GC micro-libraries with honest
benchmarks. See also: lite-signal, lite-time, lite-bmfont, lite-vram.
