AkurAI Build
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.