Reactivity: signals & directives
The framework is client-first but build-free: native ES modules, no bundler, no transpiler. For UI that needs to change after load, it ships a tiny reactivity runtime — signals.js — written in the same spirit as the rest of the stack: a few KB of plain browser JavaScript, zero dependencies, nothing to compile.
It's progressive enhancement, not a framework you build against. The server renders real HTML; signals.js scans the DOM, finds declarative data-* directives, and wires them to reactive state. Remove the script and the page still renders — it just stops updating. See it live on the reactivity demo.
The reactive core: signal and effect
Two primitives do all the work.
A signal is a reactive cell — a value plus a list of who depends on it. Call it with no argument to read, with one argument to write:
import { signal, effect } from "/signals.js";
const count = signal(0);
count(); // read → 0
count(5); // write → 5
count(); // read → 5
An effect runs a function immediately and re-runs it whenever any signal it read during that run changes. Dependencies are tracked on read and recomputed every run, so they're always current — no dependency arrays to maintain:
effect(() => {
console.log("count is", count()); // logs 0, then again on every change
});
count(1); // → "count is 1"
count(2); // → "count is 2"
Writes are change-guarded (Object.is), so setting a signal to its current value notifies nobody. That's the whole engine; the directives below are just effects wired to the DOM.
Scopes
Directives don't float in a global namespace — they're scoped to the nearest ancestor marked data-scope. Each scope is an independent island of state, so several widgets can use a count signal on the same page without colliding.
Seed a scope's signals with a data-state attribute holding a JSON object:
<article data-scope data-state='{"count":0, "name":"world"}'>
…directives in here see `count` and `name`…
</article>
Inside a scope, expressions read and write those names directly: count, count + 1, name.toUpperCase(). Reading a name subscribes the surrounding effect to it; assigning a name (count = 3) writes the signal and triggers everything that depends on it. Names you reference but didn't seed are created on demand as reactive cells.
Directives
Each directive is an HTML attribute whose value is an expression (or, for events, a statement) evaluated against the scope.
data-text — reactive text content
Sets the element's textContent to the expression, and keeps it in sync.
<span data-text="count"></span>
<span data-text="'Hello, ' + name + '!'"></span>
data-show — conditional visibility
Toggles the element's display based on the expression's truthiness. The element stays in the DOM; only its visibility changes.
<p data-show="count > 0">You have items.</p>
<div data-show="open">…</div>
data-on:EVENT — event handlers
Runs a statement when the named DOM event fires. Any event works — data-on:click, data-on:input, data-on:submit, … The current event is available as $event:
<button data-on:click="count++">Increment</button>
<button data-on:click="count = 0">Reset</button>
<form data-on:submit="$event.preventDefault(); save()">…</form>
data-model — two-way input binding
Binds a form control's value to a signal in both directions: the input updates the signal as you type, and the signal updates the input when it changes elsewhere. Checkboxes bind their checked state; everything else binds value.
<input data-model="name" />
<input type="checkbox" data-model="open" />
<span data-text="name"></span> <!-- echoes the input live -->
data-for — list rendering
Renders a <template> once per item in an array. The binding expression is item in list or (item, index) in list; inside the template, item (and index) are in scope alongside the parent's signals.
<ul>
<template data-for="(item, index) in items">
<li><span data-text="index + 1"></span>. <span data-text="item"></span></li>
</template>
</ul>
Lists re-render when the array signal changes reference. Mutating in place (items().push(x)) won't be seen — assign a fresh array instead, which is also how you'd do it in any signals-based runtime:
<button data-on:click="items = items.concat('new')">Add</button>
How it initialises
On DOMContentLoaded (or immediately, if the script loads late), the runtime finds every [data-scope], builds its state, and binds the directives inside — descending through children but stopping at any nested [data-scope], which is initialised as its own island. If the page has no [data-scope], the runtime does nothing. It exposes a single namespaced global, window.Signals ({ signal, effect, init, version }), and the same names as ES module exports.
Load it on a page with one line, after your markup:
<script type="module" src="/signals.js"></script>
Security note: expressions are code
Directive expressions are evaluated with new Function over the scope (using with), which is eval-class power by design — it's what lets count + 1 or name.toUpperCase() just work without a parser. The consequence: only ever put author-trusted markup in directives. Never interpolate user input, request data, or database content into a data-* attribute. Treat directive text exactly as you'd treat a <script> tag — because that's what it is. For displaying untrusted values, render them server-side as escaped text and bind with data-text, whose output is plain textContent and cannot execute.
Limits
This is a deliberately small runtime for enhancing pages, not a full SPA framework. There's no virtual DOM, no keyed list diffing (a data-for re-renders its whole list on change), no computed-signal memoisation, and no component system. For the dashboards, forms, and live counters most pages actually need, that's the right amount of machinery — and it costs you nothing at build time and almost nothing at runtime.