CODE CRISPIES

JavaScript Signals — Fine-Grained Reactivity Without a Framework

3 min read javascriptarchitecture

In 2020, every JS framework had its own reactivity system. By 2024 they had all converged on roughly the same primitive: signals. Solid invented the modern term, Vue's ref, Angular's signal, Svelte 5's $state are all variations of the same idea. TC39 is now standardizing it for the language itself.

What a signal is

import { Signal } from "signal-polyfill";

const count = new Signal.State(0);

console.log(count.get());    // 0
count.set(5);
console.log(count.get());    // 5
js

Looks like a getter/setter. The magic is what comes next.

Computed values track dependencies automatically

const count = new Signal.State(0);
const doubled = new Signal.Computed(() => count.get() * 2);

console.log(doubled.get());  // 0
count.set(5);
console.log(doubled.get());  // 10
js

doubled reads count.get() inside its compute function. Signals notice the read and register the dependency. When count changes, doubled knows it's stale.

If you wrote it imperatively:

let count = 0;
let doubled = count * 2;
count = 5;
console.log(doubled);  // 0 — stale!
js

Signals close that gap. Push-based reactivity, but with pull-based reads (lazy evaluation).

Effects react to changes

import { effect } from "signal-utils/subtle/microtask-effect";

const count = new Signal.State(0);

effect(() => {
  console.log("count is", count.get());
});

count.set(5);  // logs "count is 5"
count.set(10); // logs "count is 10"
js

Whenever any signal read inside the effect changes, the effect re-runs. This is how <input value={count.get()}> becomes reactive in frameworks — they wrap rendering in an effect.

The killer feature: derive without re-computing

const items = new Signal.State([]);
const total = new Signal.Computed(() =>
  items.get().reduce((sum, x) => sum + x.price, 0)
);
const tax = new Signal.Computed(() => total.get() * 0.19);
const grandTotal = new Signal.Computed(() => total.get() + tax.get());

items.set([{ price: 10 }, { price: 20 }]);
console.log(grandTotal.get());  // 35.7

items.set([{ price: 10 }, { price: 20 }, { price: 5 }]);
console.log(grandTotal.get());  // 41.65
js

tax and grandTotal re-compute lazily, only when .get() is called and only if their dependencies actually changed. No virtual-DOM diffing. No reconciler. The graph itself tracks what's stale.

Why this matters beyond frameworks

Signals are useful any time you have derived state:

Without signals, you write either:

Signals give you the reactivity primitive without the framework opinions.

TC39 status

The proposal is at Stage 2 (as of late 2025) — drafted, but the API may shift. Polyfills exist now:

npm install signal-polyfill
bash

The polyfill follows the proposal API exactly, so when browsers ship native, you swap one import:

// Before:
import { Signal } from "signal-polyfill";
// After (browser support):
const { Signal } = globalThis;
js

Caveats

What this kills (over time)

When signals ship natively (Chrome / Safari / Firefox roadmap suggests 2027), the framework you choose becomes a question of templating and router, not reactivity. Pick the syntax you like; the engine is shared.


Practice fundamental JS in the js-variables and js-events modules on Code Crispies.