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.

counter.tsmain.tsx
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);
1 / 4
import {  } from "elements-kit/signals";

const name = ("Alice");
const name: Updater<string> & Computed<string>
(); // "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>
main.tsx
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);
1 / 3
import { ,  } from "elements-kit/signals";

const  = ("Ada");
const   = ("Lovelace");

const fullName = (() => `${()} ${()}`);
const fullName: () => string
(); // "Ada Lovelace" ("Grace"); (); // "Grace Lovelace"

A computed is read-only — you can only call it with no arguments.

main.tsx
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);
1 / 4
import { ,  } from "elements-kit/signals";

const  = ("light");

const stop = (() => {
const stop: () => void
// Reads theme — becomes a dependency automatically ... = (); }); ("dark"); // body.dataset.theme = "dark" (); // Unsubscribe — no more updates

Effects 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()));
main.tsx
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 = (() => {
const stop: () => void
(() => .("user:", ())); (() => .("page:", ())); }); ("Bob"); // → user: Bob ("/about"); // → page: /about (); // Both effects disposed — silence

Use 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.

main.tsx
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 starts

onCleanup 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.

main.tsx
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)
main.tsx
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 dependency

Mutating 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 notify

Option 2 — Reassignment:

const items = signal<string[]>([]);

items([...items(), "hello"]);  // ✅ Creates new array

The 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);
main.tsx
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);
computed<number>(getter: (previousValue?: number | undefined) => number): () => number

Creates a lazily-evaluated computed value.

The getter is only called when the computed value is read and one of its dependencies has changed since the last evaluation. If nothing has changed the cached value is returned without re-running getter.

Computed values are read-only; they cannot be set directly.

@paramgetter - Pure function deriving a value from other reactive sources. Receives the previous value as an optional optimisation hint.@example
const a = signal(1);
const b = signal(2);
const sum = computed(() => a() + b());

sum(); // → 3
a(10);
sum(); // → 12  (re-evaluated lazily)
} 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;
}
main.tsx
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.

See also

  • Stores — shared reactive state.
  • Elements — use signals as JSX children/props.
  • Promise — reactive state over any Promise.
  • Async — start/stop/run a reactive async controller.