Menu
Public repository / master
AkurAI-Framework / site/content/docs/reactivity.md
Latest change 7003a93dcc0adf28fa6dc8af98472bbcf4f2091e - feat(frontend): signals + directives reactivity runtime by Ólafur Búi Ólafsson
# 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](/reactivity).
## 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:
```js
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:
```js
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:
```html
<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.
```html
<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.
```html
<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`:
```html
<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`.
```html
<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.
```html
<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:
```html
<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:
```html
<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.