reactive lets you build a framework-free TypeScript browser UI with signals, keyed list rendering and in-place DOM updates, in one package with no runtime dependencies.
It replaces the change listeners, list re-renders and element builder you would otherwise write between state and the page. It ships as ESM TypeScript source rather than compiled JavaScript, so it needs a bundler or runtime that compiles TypeScript. Releases follow semantic versioning, and it is licensed under Apache-2.0.
reactive is built for a small browser app in plain TypeScript that needs no component framework.
- Signals, computed values and effects track their dependencies on their own. When a write returns, every effect that depends on it has already run, except those still running, which each finish before any re-run they need.
createCollectionandbindListrender a keyed list. Changing one item repaints only its row, and a reorder keeps the existing rows.createRootdisposes a view's effects at once, andresourceloads async data without painting a late answer over the next view.elbuilds elements without HTML strings, so it works under a strict Content Security Policy.patchupdates a subtree by reusing the elements on the page, so focus and scroll position survive a re-render.- The signals, stores, collections and event bus need no DOM, so they also run under Node, Deno, Bun or Workers.
Consider Solid if you want a full UI framework, with components, JSX, Suspense for async data and server-side rendering.
npx jsr add @cplieger/reactive
# or
npm i @cplieger/reactiveA signal holds a value, a computed value derives from signals, and an effect re-runs when what it read changes:
import { signal, computed, effect, batch } from "@cplieger/reactive";
const count = signal(0);
const doubled = computed(() => count.value * 2);
const dispose = effect(() => {
console.log(count.value, doubled.value);
return undefined; // an effect returns undefined or a cleanup function
});
batch(() => {
count.value = 1;
count.value = 2;
}); // the effect ran once, with 2 and 4, before batch returned
dispose();A collection and bindList render a list. Updating one item repaints only its row:
import { bindList, createCollection, el } from "@cplieger/reactive";
interface Todo {
id: string;
title: string;
done: boolean;
}
const todos = createCollection<Todo>((todo) => todo.id);
const list = el("ul");
document.body.append(list);
bindList(list, todos, {
mount: () => el("li"),
update: (row, todo) => {
row.textContent = todo.done ? `${todo.title} (done)` : todo.title;
},
});
todos.setAll([
{ id: "a", title: "Write docs", done: false },
{ id: "b", title: "Ship", done: false },
]);
todos.update("a", (todo) => ({ ...todo, done: true })); // only the first row repaintspatch takes freshly built elements and applies them to a subtree, keeping the elements already on the page:
import { effect, el, patch, signal } from "@cplieger/reactive";
const name = signal("world");
const header = el("header");
document.body.append(header);
effect(() => {
patch(header, el("h1", { className: "title" }, `Hello, ${name.value}`));
return undefined;
});
name.value = "reactive"; // the same <h1> stays on the page with its new textcreateRoot owns what a view starts, and resource loads data for it. Disposing the root aborts the request, and a late answer writes nothing:
import { createRoot, effect, el, resource, signal } from "@cplieger/reactive";
const id = signal("a");
const view = el("p");
document.body.append(view);
const dispose = createRoot((dispose) => {
const item = resource(
() => id.value,
(value, signal) => fetch(`/items/${value}`, { signal }).then((res) => res.text()),
);
effect(() => {
const s = item.value;
view.textContent = s.state === "ready" ? s.data : s.state;
});
return dispose;
});
id.value = "b"; // the request for "a" is aborted and its answer is dropped
dispose();- Signals:
signal,computed,effect,batch,untracked,touch,on,subscribe,isSignal,isComputedandsetEffectErrorHandler. - Ownership and async:
createRootandrunWithOwnerfor owners that dispose a tree of effects, andresourcefor async data as aLoadable. - State:
createStorefor a fixed set of typed keys,SignalMapfor keys known only at run time, andcreateCollectionfor an ordered list of keyed items. - DOM:
elbuilds elements,reconcileandbindListrender keyed lists, andpatchupdates a subtree in place.trackHandlerregisters anon*handler on an element you built withoutel, sopatchkeeps it in step. - Events:
createBusfor typed events that hold no value.
The full reference is on JSR. Signals, stores and collections and Building and updating the DOM describe how each one behaves.
When count.value = 1 returns, every effect that depends on count has already run, so the next line reads the DOM those effects wrote. There is no queue and no pending state to wait for.
batch(fn) is the only way to defer. Writes inside it coalesce, and the held effects run once, synchronously, when the outermost batch returns, even when the body throws. To run effects on a later task, make the write on a later task:
queueMicrotask(() => {
count.value = next; // effects run inside this microtask
});This is the contract @preact/signals-core documents too. The package has no flushSync() or nextTick(), because it never holds work, so there is nothing to flush.
- Effects downstream of a
computedrun only when its value changes, compared withObject.isunless you passequals. A diamond-shaped dependency graph never shows a half-updated value. - Reading a
computedwhile it is computing throwsError("Cycle detected"). - When a computed function throws, every read rethrows that error until a dependency changes.
- Setting
.valueon acomputedthrowsError("Cannot set a computed signal"). batch()runs the held effects synchronously at the end of the outermost batch, as @preact/signals-core and solid-js do.
The package leaves these out on purpose. Non-goals gives the reason for each.
- Ownership outside a root, and a public
getOwner(). - Lazy activation and an
onMountlifecycle. Signal.subtle.Watcherand notify-on-dirty.- Introspection APIs, and explicit disposal of a computed.
- Server-side rendering and per-request isolation.
- Suspense, or a resource that throws while loading.
- Transactions, a custom scheduler, and a flush barrier such as
flushSyncornextTick. - Controlled form inputs.
patchsyncs form values, so if a view can re-render while a user types, drive that field from an effect instead. - Writing a signal from inside a computed. The computed's own subscribers are not woken, so make that write in an effect.
- Signals, stores and collections covers the flush model, effects, owners, resources, stores, collections and the event bus, for a reader writing state code.
- Building and updating the DOM covers how
el,reconcile,bindListandpatchdecide what to keep, for a reader rendering with them. - Non-goals lists what the package leaves out and why.
- The signal engine follows the design of @preact/signals-core in its dependency graph, its glitch-free computed refresh, its run-on-write effects and its untracked
subscribecallback. - The
on()helper follows Solid'son(), anduntracked()is Solid'suntrack()under the name Preact gives it.createRoot,runWithOwnerand owned nested effects follow Solid's ownership model.
Issues and pull requests are welcome. See CONTRIBUTING.md.
This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.
This project was built with AI-assisted tooling using Claude, GPT, and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
Apache-2.0. See LICENSE.
Third-party attributions are in THIRD_PARTY_NOTICES.md.