Live demo~3 KB0 dependenciesOpen & fork

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.


01 The whole engine

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.


02 Live

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.

Demo 01 · Signal + Computed
0 Count
0 Doubled
counter-demo.js
import { 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;
Demo 02 · Live binding
binding-demo.js
import { 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'; });
Demo 03 · A graph of computeds
  • 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.js
import { 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

03 How it fits

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.