A reactive state engine in pure JS, three kilobytes
Signals, derived values, and effects in about twenty-five lines — no virtual DOM, no compiler, no dependencies. Every demo below runs on exactly this code.
Three primitives, one dependency graph
The whole library is three functions — signal, effect, and computed. A signal holds a value and remembers who read it. An effect re-runs when any signal it touched changes. A computed is an effect that stores its result.
reactive.js · the running source'use strict';
/*
* Minimal reactive primitives — signal, effect, computed.
* The dependency graph, change detection, and scheduler in one file.
*/
let current = null;
export function signal(initial) {
let value = initial;
const subs = new Set();
return {
get value() {
if (current) subs.add(current);
return value;
},
set value(next) {
if (next === value) return;
value = next;
subs.forEach((fn) => fn());
},
};
}
export function effect(fn) {
const run = () => {
current = run;
try {
fn();
} finally {
current = null;
}
};
run();
}
export function computed(fn) {
const s = signal();
effect(() => {
s.value = fn();
});
return s;
}
That is the dependency graph, the change detection, and the scheduler — all of it. Everything below runs on exactly this.
Three things wired by one function
Every widget below runs on exactly the code above — no framework, no build step, no runtime beyond the browser.
counter-demo.jsimport { signal, computed, effect } from './reactive.js';
const count = signal(0);
const doubled = computed(() => count.value * 2);
// bind each to the DOM, once
effect(() => { countEl.textContent = count.value; });
effect(() => { doubleEl.textContent = doubled.value; });
incBtn.onclick = () => count.value++;
decBtn.onclick = () => count.value--;
resetBtn.onclick = () => count.value = 0;
binding-demo.jsimport { signal, computed, effect } from './reactive.js';
const name = signal('');
input.oninput = () => { name.value = input.value; };
const hello = computed(() =>
name.value ? 'Hello, ' + name.value : 'Start typing…'
);
effect(() => { greetEl.textContent = hello.value; });
effect(() => { countEl.textContent = name.value.length + ' chars'; });
- Patch cable ($8.99)0
- Pop filter ($19.00)0
- Studio headphones ($99.00)0
- Subtotal
- $0.00
- Tax (7%)
- $0.00
- Total
- $0.00
cart-demo.jsimport { signal, computed, effect } from './reactive.js';
// one signal per quantity
const qty = items.map(i => signal(0));
// computeds chain off each other
const subtotal = computed(() =>
sum(items, (i, n) => i.price * qty[n].value));
const tax = computed(() => subtotal.value * 0.07);
const total = computed(() => subtotal.value + tax.value);
// bumping any qty cascades to total — free
Three ideas, nothing else
Signal
A box around a value. Reading it during an effect quietly records that effect as a subscriber; writing it notifies them. That bookkeeping is the entire trick.
Effect
A function that runs once, notes every signal it touched, and re-runs whenever any of them change. Your DOM updates are just effects.
Computed
An effect that stores its result in a signal — so derived values are reactive too, and chains of them update in order, automatically.
No virtual DOM, no diffing, no build step. When a value changes, only the effects that read it run again — nothing else on the page is touched. That precision is why it stays small and stays fast.
Fork it, break it, ship it
Twenty-five lines is small enough to read in a sitting and own forever. Take it as the seed of your next interface — or let us help you build on it.