@just-dom/signals
Fine-grained reactive signals for Just DOM
@just-dom/signals adds fine-grained reactivity to Just DOM: a single source of truth that keeps your data and UI in sync automatically, without manual subscription management or full re-renders.
The model is the same used by SolidJS and Angular Signals — a lightweight push-pull system where reads inside an effect automatically subscribe to changes.
Installation
npm install @just-dom/signalsNo peer dependencies. The package works standalone alongside any Just DOM setup.
Quick start
import DOM, { createRoot } from "just-dom";
import { createSignal, computed, reactive, effect, when, each } from "@just-dom/signals";
const [count, setCount] = createSignal(0);
const double = computed(() => count() * 2);
const app = DOM.div({}, [
DOM.button({ onclick: () => setCount((c) => c - 1) }, ["-"]),
DOM.span({}, [reactive(count)]),
DOM.button({ onclick: () => setCount((c) => c + 1) }, ["+"]),
DOM.p({}, ["doubled: ", reactive(double)]),
]);
createRoot("app", app);reactive(count) returns a Text node that updates itself in place — no refs, no textContent = calls anywhere.
Primitives
createSignal
function createSignal<T>(initial: T): [Signal<T>, SignalSetter<T>]Creates a reactive value. The getter get() is a plain function — calling it inside an effect or computed registers a subscription automatically. The setter accepts either a new value or an updater function:
const [name, setName] = createSignal("world");
setName("just-dom"); // direct value
setName((prev) => prev + "!"); // updaterWriting the same value (Object.is equality) is a no-op — effects do not re-run.
effect
function effect(fn: EffectFn): EffectDispose
function effect(el: Element, fn: EffectFn): EffectDisposeRuns fn immediately, then re-runs it whenever any signal read inside changes. Returns a dispose function that stops the effect and frees all subscriptions.
Without el — dispose is manual. Use this form when the effect lives outside the element tree or you need precise control over teardown:
const [active, setActive] = createSignal(false);
const btn = DOM.button({}, ["Toggle"]);
const stop = effect(() => {
btn.className = active() ? "active" : "";
});
// call when you remove btn from the DOM
stop();With el — self-disposes automatically when the element leaves the live document. The returned dispose can still be called early for explicit teardown. This is the natural form to use inside a callback ref:
DOM.button({
ref: (el) => {
effect(el, () => {
el.className = active() ? "active" : "";
el.disabled = !active();
});
},
onclick: () => setActive((a) => !a),
}, ["Toggle"]);The effect is declared right next to the element — no separate variable, no external cleanup code.
fn may return a cleanup callback that runs before each re-execution and on final dispose:
effect(() => {
const id = setInterval(() => console.log(count()), 1000);
return () => clearInterval(id);
});Dependencies are re-evaluated on each run — signals read inside a conditional branch are tracked only while that branch executes. Switching branches drops stale subscriptions automatically.
computed
function computed<T>(fn: () => T): Signal<T>Returns a read-only signal whose value is derived from fn. Re-evaluates when dependencies change and propagates to any effect or computed that reads it.
const [price, setPrice] = createSignal(100);
const [qty, setQty] = createSignal(3);
const total = computed(() => price() * qty());
DOM.span({}, [reactive(total)]);Chains work naturally:
const vat = computed(() => total() * 0.22);
const grandTotal = computed(() => total() + vat());reactive
function reactive(signal: () => unknown): TextCreates a Text DOM node whose nodeValue stays in sync with signal(). Pass it wherever Just DOM accepts a child node — no extra setup.
DOM.p({}, ["Count is: ", reactive(count)]);
DOM.h1({}, [reactive(() => `Hello, ${name()}`)]);The text node self-cleans: once removed from the live document its internal effect disposes automatically.
The self-cleanup fires on the next signal change after removal — one-tick lag. This is intentional and never observable in practice.
when
function when(
condition: () => unknown,
render: () => Node | string | null | undefined,
options?: { cache?: boolean },
): DocumentFragmentCreates an anchored DOM region that renders render() while condition() is truthy. Static siblings around the returned fragment are left untouched:
const [show, setShow] = createSignal(false);
DOM.section({}, [
DOM.h2({}, ["Static title"]),
when(show, () => DOM.p({}, ["Panel is visible"])),
DOM.button(
{ onclick: () => setShow((v) => !v) },
[reactive(() => show() ? "Hide" : "Show")],
),
]);Use object branches for an else case:
when(show, {
then: () => DOM.p({}, ["Visible"]),
else: () => DOM.p({}, ["Hidden"]),
});By default a branch is recreated when it is shown again. Pass { cache: true } to keep branch nodes and move them back later, preserving DOM state such as input values.
The anchored region self-cleans after it is removed from the live document, with the same one-tick lag as reactive.
each
function each<T, K extends string | number | symbol>(
items: () => readonly T[],
key: (item: T, index: number) => K,
renderItem: (item: () => T, index: () => number) => Node | string | null | undefined,
): DocumentFragmentRenders a keyed list. Existing nodes are moved when the array order changes, so event listeners, refs, input state, and child DOM identity are preserved.
renderItem receives an item signal and an index signal. Use item() inside nested reactive() or effect() calls when item data should update without recreating the node:
const [todos, setTodos] = createSignal([
{ id: "a", label: "Write docs" },
{ id: "b", label: "Ship package" },
]);
DOM.ul({}, [
each(
todos,
(todo) => todo.id,
(todo, index) => DOM.li({}, [
reactive(() => `${index() + 1}. ${todo().label}`),
]),
),
]);Keys must be unique and stable. Duplicate keys throw, and changing a key is treated as removing the old node and adding a new one.
The anchored list region self-cleans after it is removed from the live document.
Reactive attributes and styles
Use effect(el, fn) inside a callback ref to bind attributes, classes, and styles reactively. The element is available directly — no separate variable needed:
const [theme, setTheme] = createSignal<"light" | "dark">("light");
const box = DOM.div(
{
ref: (el) => {
effect(el, () => {
el.style.background = theme() === "dark" ? "#1a1a1a" : "#fff";
el.style.color = theme() === "dark" ? "#fff" : "#1a1a1a";
});
},
style: { padding: "16px" },
},
[DOM.p({}, [reactive(theme)])],
);The effect self-disposes when box is removed from the DOM — no cleanup code needed outside the element definition.
If you also need to access the element from outside the tree, save the ref in the same callback:
import { createRef } from "just-dom";
const boxRef = createRef<"div">();
const box = DOM.div({
ref: (el) => {
boxRef.current = el;
effect(el, () => {
el.style.background = theme() === "dark" ? "#1a1a1a" : "#fff";
});
},
});Conditional rendering
Use when to surgically add or remove children without touching siblings:
const [show, setShow] = createSignal(false);
DOM.section({}, [
DOM.h2({}, ["Static title"]), // never touched
when(show, () =>
DOM.div({ style: { color: "#16a34a" } }, ["Panel is visible"])
),
DOM.button(
{ onclick: () => setShow((v) => !v) },
[reactive(() => show() ? "Hide" : "Show")],
),
]);The static siblings (h2, button) are never recreated — only the panel is added or removed.
Multiple signals in one effect
An effect subscribes to every signal it reads in a given run. Reading two signals means the effect re-runs whenever either changes:
const [first, setFirst] = createSignal("Ada");
const [last, setLast] = createSignal("Lovelace");
DOM.span({
ref: (el) => {
effect(el, () => {
el.textContent = `${first()} ${last()}`;
});
},
});Updating first or last each trigger one re-run.
Limits and caveats
No deep reactivity
Signals track the reference, not the contents of an object or array. Mutating a property does not trigger updates:
const [items, setItems] = createSignal(["a", "b"]);
// ✗ Does not trigger effects — same reference
items().push("c");
// ✓ Replace the array
setItems([...items(), "c"]);No batching in V1
Each setter call immediately flushes all subscribed effects. Two setters in sequence run effects twice:
setFirst("Grace"); // effects run
setLast("Hopper"); // effects run againFor most UIs this is imperceptible. A batch() API may arrive in a future release.
effect(fn) without el — dispose is manual
reactive text nodes and effect(el, fn) self-clean automatically. The bare effect(fn) form does not — you must call the returned dispose when tearing down the UI:
const stop = effect(() => {
externalEl.className = active() ? "on" : "off";
});
// when removing externalEl:
stop();The simplest fix is to use effect(el, fn) inside a callback ref instead.
Effects run synchronously
effect(fn) runs fn on the current call stack. Avoid layout-forcing DOM reads (like offsetHeight) inside effects on hot signal paths.
computed is eager
A computed recalculates immediately when a dependency changes, even if nothing is currently reading it. Lazy computed (recalculate only on read) is not supported in V1.
API reference
createSignal<T>
| Signature | (initial: T) => [Signal<T>, SignalSetter<T>] |
| Getter | () => T — reads the value; subscribes if called inside an effect |
| Setter | (next: T | ((prev: T) => T)) => void — updates the value and notifies subscribers |
| No-op rule | Writing the same value (Object.is) does nothing |
effect
| Signatures | (fn: EffectFn) => EffectDispose · (el: Element, fn: EffectFn) => EffectDispose |
| Runs | Immediately, then on every dependency change |
| Returns | Dispose function — always returned; call early for explicit teardown |
With el | Self-disposes when el leaves the live document (one-tick lag) |
| Cleanup | fn may return () => void; called before each re-run and on dispose |
computed<T>
| Signature | (fn: () => T) => Signal<T> |
| Returns | A read-only signal backed by the derived computation |
| Updates | Eagerly when any dependency changes |
reactive
| Signature | (signal: () => unknown) => Text |
| Returns | A live Text DOM node |
| Self-cleanup | Disposes its internal effect when removed from the live document |
when
| Signatures | (condition, render, options?) => DocumentFragment · (condition, branches, options?) => DocumentFragment |
| Returns | An anchored DOM region as a DocumentFragment |
| Cache option | { cache: true } reuses branch nodes instead of recreating them |
each
| Signature | (items, key, renderItem) => DocumentFragment |
| Returns | An anchored keyed list region |
| Render item | Receives (itemSignal, indexSignal) |
| Keys | Must be unique string, number, or symbol values |
Exports
| Export | Description |
|---|---|
createSignal | Reactive primitive — getter/setter pair |
effect | Auto-tracking side effect with optional self-cleanup |
computed | Derived read-only signal |
reactive | Auto-updating Text DOM node |
when | Anchored conditional DOM region |
each | Keyed list renderer that moves existing DOM nodes |
Signal<T> | Getter function type |
SignalSetter<T> | Setter function type |
SignalPair<T> | [Signal<T>, SignalSetter<T>] |
EffectFn | () => void | (() => void) |
EffectDispose | () => void |