Signals
Fine-grained reactivity with signal, computed, effect, and more.
Signals are the reactive core of ElementsKit. Every UI update, derived value, and side effect is driven by three primitives: signal, computed, and effect.
import { signal, computed, effect, untracked, trigger } from "elements-kit/signals";
export const count = signal(0);
export const doubled = computed(() => count() * 2);
export const logs = signal<string[]>([]);
effect(() => {
untracked(logs).push(`count: ${count()}, doubled: ${doubled()}`);
trigger(logs);
});
signal
A signal holds a single reactive value. Call it with no arguments to read, with an argument to write.
import { signal } from "elements-kit/signals";
// Create a reactive value with an initial state
const count = signal(0);
import { } from "elements-kit/signals";
const name = ("Alice");(); // "Alice"
("Bob"); // write
(); // "Bob"Signals are synchronous — every write immediately notifies dependents.
Reactive children need () =>
In JSX, signal/computed values must be wrapped in a function:
// ❌ Wrong — reads once, never updates
<p>{count()}</p>
// ✅ Correct — reads on every change
<p>{() => count()}</p>import { signal, effect } from "elements-kit/signals";
test("reads and writes", () => {
const count = signal(0);
expect(count()).toBe(0);
count(5);
expect(count()).toBe(5);
});
test("notifies effects synchronously on write", () => {
const count = signal(0);
const seen: number[] = [];
const stop = effect(() => seen.push(count()));
count(1);
count(2);
stop();
expect(seen).toEqual([0, 1, 2]);
});
test("dedupes equal writes (===)", () => {
const count = signal(0);
let runs = 0;
const stop = effect(() => {
count();
runs++;
});
count(0); // same value
stop();
expect(runs).toBe(1);
});
computed
A computed derives a value from one or more signals. It is lazy — only re-evaluates when a dependency has changed and the value is read again. Results are cached between reads.
import { signal, computed } from "elements-kit/signals";
const price = signal(10);
const qty = signal(3);
import { , } from "elements-kit/signals";
const = ("Ada");
const = ("Lovelace");
const fullName = (() => `${()} ${()}`);(); // "Ada Lovelace"
("Grace");
(); // "Grace Lovelace"A computed is read-only — you can only call it with no arguments.
import { signal, computed } from "elements-kit/signals";
test("derives from signals", () => {
const a = signal(2);
const b = signal(3);
const sum = computed(() => a() + b());
expect(sum()).toBe(5);
a(10);
expect(sum()).toBe(13);
});
test("is lazy: no work before read", () => {
const a = signal(1);
let runs = 0;
const derived = computed(() => {
runs++;
return a() * 2;
});
expect(runs).toBe(0);
derived();
expect(runs).toBe(1);
});
test("caches between reads without dep change", () => {
const a = signal(1);
let runs = 0;
const derived = computed(() => {
runs++;
return a();
});
derived();
derived();
derived();
expect(runs).toBe(1);
});
effect
An effect runs a side effect whenever its signal dependencies change. It runs immediately on creation to collect dependencies, then re-runs on every change. Returns a stop function. If it writes a signal it read, it runs again once it finishes if that value changed, until its deps settle.
import { signal, effect } from "elements-kit/signals";
const count = signal(0);
import { , } from "elements-kit/signals";
const = ("light");
const stop = (() => { // Reads theme — becomes a dependency automatically
... = ();
});
("dark"); // body.dataset.theme = "dark"
(); // Unsubscribe — no more updatesEffects track dependencies dynamically — only the signals actually read during the last run are tracked.
Reading signals outside effects
Signals only trigger updates when read inside a reactive context (effect, computed, or reactive JSX). Reading outside gives you the current value but won't re-run:
const name = signal("Alice");
// Logs once — not reactive
console.log(name());
// Re-runs whenever name changes
effect(() => console.log(name()));import { signal, effect } from "elements-kit/signals";
test("runs immediately on creation", () => {
const a = signal(1);
let runs = 0;
const stop = effect(() => {
a();
runs++;
});
expect(runs).toBe(1);
stop();
});
test("reruns when dependencies change", () => {
const a = signal(0);
const seen: number[] = [];
const stop = effect(() => seen.push(a()));
a(1);
a(2);
stop();
expect(seen).toEqual([0, 1, 2]);
});
test("stop() disposes — no more runs", () => {
const a = signal(0);
let runs = 0;
const stop = effect(() => {
a();
runs++;
});
stop();
a(1);
a(2);
expect(runs).toBe(1);
});
effectScope
Groups multiple effects under a single lifecycle. Calling the returned stop function disposes all effects at once.
import { , , } from "elements-kit/signals";
const = ("Alice");
const = ("/home");
// All effects inside are grouped together
const stop = (() => { (() => .("user:", ()));
(() => .("page:", ()));
});
("Bob"); // → user: Bob
("/about"); // → page: /about
(); // Both effects disposed — silenceUse effectScope when you want to start and stop a group of related effects together — e.g. tied to a component's lifetime.
JSX already scopes for you
Function components, intrinsic elements, fragments, and <For> items each create their own effectScope. You only need to create one by hand in custom-element lifecycle hooks or other non-JSX entry points — see Scopes & cleanup.
import { signal, effect, effectScope } from "elements-kit/signals";
test("groups effects under one lifetime", () => {
const a = signal(0);
const b = signal(0);
const seen: string[] = [];
const stop = effectScope(() => {
effect(() => seen.push(`a:${a()}`));
effect(() => seen.push(`b:${b()}`));
});
a(1);
b(1);
expect(seen).toEqual(["a:0", "b:0", "a:1", "b:1"]);
stop();
});
test("stop disposes every effect in the scope", () => {
const a = signal(0);
let aRuns = 0;
let bRuns = 0;
const stop = effectScope(() => {
effect(() => {
a();
aRuns++;
});
effect(() => {
a();
bRuns++;
});
});
stop();
a(1);
a(2);
expect(aRuns).toBe(1);
expect(bRuns).toBe(1);
});
onCleanup
Registers a cleanup callback inside the currently running effect or effectScope. The callback runs before the effect re-runs and when the effect is disposed.
import { signal, effect, onCleanup } from "elements-kit/signals";
const url = signal("/api/data");
effect(() => {
const controller = new AbortController();
fetch(url(), { signal: controller.signal })
.then((r) => r.json())
.then(console.log);
// Runs before the next fetch (url changed) or on stop()
onCleanup(() => controller.abort());
});
url("/api/other"); // previous fetch aborted, new one startsonCleanup works at any call depth inside the effect — you can call it anywhere without passing anything down.
You can use onCleanup inside a computed getter to manage resources that need cleanup (like timers or subscriptions). The cleanup runs before the computed re-evaluates (when dependencies change), and also when the computed loses its last subscriber (to prevent resource leaks).
import { signal, computed, onCleanup } from "elements-kit/signals";
const source = signal(0);
const derived = computed(() => {
// Setup resource
const id = setInterval(() => {
// ...do something...
}, 1000);
// Register cleanup
onCleanup(() => clearInterval(id));
return source() * 2;
});This ensures that any resources created inside the computed are always cleaned up at the right time.
import { signal, effect, onCleanup } from "elements-kit/signals";
test("runs before each rerun", () => {
const a = signal(0);
const events: string[] = [];
const stop = effect(() => {
const v = a();
events.push(`run:${v}`);
onCleanup(() => events.push(`cleanup:${v}`));
});
a(1);
a(2);
stop();
expect(events).toEqual([
"run:0",
"cleanup:0",
"run:1",
"cleanup:1",
"run:2",
"cleanup:2",
]);
});
test("runs on dispose", () => {
let cleaned = false;
const stop = effect(() => {
onCleanup(() => {
cleaned = true;
});
});
expect(cleaned).toBe(false);
stop();
expect(cleaned).toBe(true);
});
test("works at any call depth", () => {
let cleaned = false;
const helper = () => onCleanup(() => (cleaned = true));
const stop = effect(() => helper());
stop();
expect(cleaned).toBe(true);
});
batch
Defers all signal notifications until the batch completes. Use it to group multiple writes into a single update pass — avoids intermediate re-renders.
import { signal, effect, batch } from "elements-kit/signals";
const x = signal(1);
const y = signal(2);
effect(() => console.log(x(), y()));
// → 1 2 (initial run)
// Without batch: effect would run twice (once per write)
batch(() => {
x(10);
y(20);
});
// → 10 20 (single run)import { signal, effect, batch } from "elements-kit/signals";
test("coalesces multiple writes into one effect run", () => {
const x = signal(0);
const y = signal(0);
let runs = 0;
const stop = effect(() => {
x();
y();
runs++;
});
expect(runs).toBe(1);
batch(() => {
x(1);
y(2);
});
expect(runs).toBe(2);
stop();
});
test("without batch, each write notifies", () => {
const x = signal(0);
const y = signal(0);
let runs = 0;
const stop = effect(() => {
x();
y();
runs++;
});
x(1);
y(2);
expect(runs).toBe(3);
stop();
});
untracked
Reads a signal without creating a dependency. The current effect will not re-run when that signal changes.
import { signal, effect, untracked } from "elements-kit/signals";
const count = signal(0);
const secret = signal("hidden");
effect(() => {
// Tracked — effect reruns when count changes
console.log("count:", count());
// Untracked — effect does NOT rerun when secret changes
console.log("secret:", untracked(() => secret()));
});
count(1); // triggers re-run
secret("x"); // silent — not a dependencyMutating vs. reassigning
Arrays and objects are passed by reference. Mutating them (push, pop, arr[0] = x) doesn't notify subscribers — the signal value (reference) hasn't changed.
const items = signal<string[]>([]);
// ❌ Won't trigger
items().push("Hello");Option 1 — Mutation + trigger (for expensive data):
const items = signal<string[]>([]);
items().push("hello");
trigger(items); // ✅ Mutate in place, then notifyOption 2 — Reassignment:
const items = signal<string[]>([]);
items([...items(), "hello"]); // ✅ Creates new arrayThe same applies to objects:
const user = signal({ name: "Alice" });
// ❌ Won't trigger
user().name = "Bob";
// ✅ Reassignment
user({ ...user(), name: "Bob" });
// ✅ Or mutation + trigger
user().name = "Bob";
trigger(user);import { signal, effect, untracked } from "elements-kit/signals";
test("reads without creating a dependency", () => {
const a = signal(0);
const b = signal(0);
let runs = 0;
const stop = effect(() => {
a();
untracked(() => b());
runs++;
});
expect(runs).toBe(1);
b(10);
expect(runs).toBe(1);
a(1);
expect(runs).toBe(2);
stop();
});
test("returns the current value", () => {
const a = signal(42);
expect(untracked(() => a())).toBe(42);
});
@reactive decorator
Backs a class field with a signal transparently. The field reads and writes naturally — no () needed on the consumer side.
import { , } from "elements-kit/signals";
class {
@()
= 0;
= computed(() => this. * 2);}
const = new ();
. = 5;
.(); // → 10@reactive is equivalent to a private signal + get/set accessor — it just removes the boilerplate. Works with computed too: pass the signal source to make a field read-only.
class Store {
#items = signal<string[]>([]);
// Read-only reactive field backed by a computed
@reactive((s) => computed(() => s.#items().length))
count = 0;
}import { reactive, effect, computed } from "elements-kit/signals";
class Counter {
@reactive() count = 0;
doubled = computed(() => this.count * 2);
}
test("field read/write is transparent", () => {
const c = new Counter();
expect(c.count).toBe(0);
c.count = 5;
expect(c.count).toBe(5);
});
test("reactive field drives effects and computeds", () => {
const c = new Counter();
const seen: number[] = [];
const stop = effect(() => seen.push(c.count));
c.count = 1;
c.count = 2;
stop();
expect(seen).toEqual([0, 1, 2]);
expect(c.doubled()).toBe(4);
});
Store
A store is a class with @reactive fields. It holds state — no render(), no DOM. See Stores for full documentation.