JUST DOMJUST-DOM
Official Plugins

@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/signals

No 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 + "!"); // updater

Writing 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): EffectDispose

Runs 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): Text

Creates 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 },
): DocumentFragment

Creates 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,
): DocumentFragment

Renders 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 again

For 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 ruleWriting the same value (Object.is) does nothing

effect

Signatures(fn: EffectFn) => EffectDispose · (el: Element, fn: EffectFn) => EffectDispose
RunsImmediately, then on every dependency change
ReturnsDispose function — always returned; call early for explicit teardown
With elSelf-disposes when el leaves the live document (one-tick lag)
Cleanupfn may return () => void; called before each re-run and on dispose

computed<T>

Signature(fn: () => T) => Signal<T>
ReturnsA read-only signal backed by the derived computation
UpdatesEagerly when any dependency changes

reactive

Signature(signal: () => unknown) => Text
ReturnsA live Text DOM node
Self-cleanupDisposes its internal effect when removed from the live document

when

Signatures(condition, render, options?) => DocumentFragment · (condition, branches, options?) => DocumentFragment
ReturnsAn anchored DOM region as a DocumentFragment
Cache option{ cache: true } reuses branch nodes instead of recreating them

each

Signature(items, key, renderItem) => DocumentFragment
ReturnsAn anchored keyed list region
Render itemReceives (itemSignal, indexSignal)
KeysMust be unique string, number, or symbol values

Exports

ExportDescription
createSignalReactive primitive — getter/setter pair
effectAuto-tracking side effect with optional self-cleanup
computedDerived read-only signal
reactiveAuto-updating Text DOM node
whenAnchored conditional DOM region
eachKeyed 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

On this page