Skip to content

Signals

Signals are the reactive core of ElementsKit. Every UI update, derived value, and side effect is driven by three primitives: signal, computed, and effect.

signal

A signal holds a single reactive value. Call it with no arguments to read, with an argument to write.

import {
function signal<T>(): Updater<T> & Computed<T> (+1 overload)

Creates a mutable reactive signal.

  • Read: call with no arguments β†’ returns the current value and subscribes the active tracking context.
  • Write: call with a value β†’ updates the signal and schedules downstream effects if the value changed.

@example

const count = signal(0);
count(); // β†’ 0 (read)
count(1); // write – effects depending on count will re-run
count(); // β†’ 1

signal
} from "elements-kit/signals";
const name =
signal<string>(initialValue: string): Updater<string> & Computed<string> (+1 overload)

Creates a mutable reactive signal.

  • Read: call with no arguments β†’ returns the current value and subscribes the active tracking context.
  • Write: call with a value β†’ updates the signal and schedules downstream effects if the value changed.

@example

const count = signal(0);
count(); // β†’ 0 (read)
count(1); // write – effects depending on count will re-run
count(); // β†’ 1

signal
("Alice");
const name: Updater<string> & Computed<string>
const name: () => string (+1 overload)
name
(); // "Alice"
const name: (value: string) => void (+1 overload)
name
("Bob"); // write
const name: () => string (+1 overload)
name
(); // "Bob"

Signals are synchronous β€” every write immediately notifies dependents.

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 {
function signal<T>(): Updater<T> & Computed<T> (+1 overload)

Creates a mutable reactive signal.

  • Read: call with no arguments β†’ returns the current value and subscribes the active tracking context.
  • Write: call with a value β†’ updates the signal and schedules downstream effects if the value changed.

@example

const count = signal(0);
count(); // β†’ 0 (read)
count(1); // write – effects depending on count will re-run
count(); // β†’ 1

signal
,
function computed<T>(getter: (previousValue?: T) => T): () => T

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.

@param ― getter - 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)

computed
} from "elements-kit/signals";
const
const firstName: Updater<string> & Computed<string>
firstName
=
signal<string>(initialValue: string): Updater<string> & Computed<string> (+1 overload)

Creates a mutable reactive signal.

  • Read: call with no arguments β†’ returns the current value and subscribes the active tracking context.
  • Write: call with a value β†’ updates the signal and schedules downstream effects if the value changed.

@example

const count = signal(0);
count(); // β†’ 0 (read)
count(1); // write – effects depending on count will re-run
count(); // β†’ 1

signal
("Ada");
const
const lastName: Updater<string> & Computed<string>
lastName
=
signal<string>(initialValue: string): Updater<string> & Computed<string> (+1 overload)

Creates a mutable reactive signal.

  • Read: call with no arguments β†’ returns the current value and subscribes the active tracking context.
  • Write: call with a value β†’ updates the signal and schedules downstream effects if the value changed.

@example

const count = signal(0);
count(); // β†’ 0 (read)
count(1); // write – effects depending on count will re-run
count(); // β†’ 1

signal
("Lovelace");
const fullName =
computed<string>(getter: (previousValue?: string | undefined) => string): () => string

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.

@param ― getter - 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)

computed
(() => `${
const firstName: () => string (+1 overload)
firstName
()} ${
const lastName: () => string (+1 overload)
lastName
()}`);
const fullName: () => string
const fullName: () => string
fullName
(); // "Ada Lovelace"
const firstName: (value: string) => void (+1 overload)
firstName
("Grace");
const fullName: () => string
fullName
(); // "Grace Lovelace"

A computed is read-only β€” you can only call it with no arguments.


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.

import {
function signal<T>(): Updater<T> & Computed<T> (+1 overload)

Creates a mutable reactive signal.

  • Read: call with no arguments β†’ returns the current value and subscribes the active tracking context.
  • Write: call with a value β†’ updates the signal and schedules downstream effects if the value changed.

@example

const count = signal(0);
count(); // β†’ 0 (read)
count(1); // write – effects depending on count will re-run
count(); // β†’ 1

signal
,
function effect(fn: () => void): () => void

Creates a reactive side-effect that runs immediately and re-runs whenever any signal or computed it read during its last execution changes.

Use

onCleanup

inside fn to register teardown logic that runs before each re-execution and on final disposal.

If effect is called inside an effectScope or another effect, the new effect is automatically owned by the outer scope and will be disposed when the scope is disposed.

@param ― fn - The side-effect body. Reactive reads inside this function establish dependency links.

@returns ― A disposal function. Call it to stop the effect and run any registered cleanup.

@example

const url = signal('/api/data');
const stop = effect(() => {
const controller = new AbortController();
fetch(url(), { signal: controller.signal });
onCleanup(() => controller.abort());
});
url('/api/other'); // previous fetch is aborted, new one starts
stop(); // final cleanup: abort the last fetch

effect
} from "elements-kit/signals";
const
const theme: Updater<string> & Computed<string>
theme
=
signal<string>(initialValue: string): Updater<string> & Computed<string> (+1 overload)

Creates a mutable reactive signal.

  • Read: call with no arguments β†’ returns the current value and subscribes the active tracking context.
  • Write: call with a value β†’ updates the signal and schedules downstream effects if the value changed.

@example

const count = signal(0);
count(); // β†’ 0 (read)
count(1); // write – effects depending on count will re-run
count(); // β†’ 1

signal
("light");
const stop =
function effect(fn: () => void): () => void

Creates a reactive side-effect that runs immediately and re-runs whenever any signal or computed it read during its last execution changes.

Use

onCleanup

inside fn to register teardown logic that runs before each re-execution and on final disposal.

If effect is called inside an effectScope or another effect, the new effect is automatically owned by the outer scope and will be disposed when the scope is disposed.

@param ― fn - The side-effect body. Reactive reads inside this function establish dependency links.

@returns ― A disposal function. Call it to stop the effect and run any registered cleanup.

@example

const url = signal('/api/data');
const stop = effect(() => {
const controller = new AbortController();
fetch(url(), { signal: controller.signal });
onCleanup(() => controller.abort());
});
url('/api/other'); // previous fetch is aborted, new one starts
stop(); // final cleanup: abort the last fetch

effect
(() => {
const stop: () => void
// Reads theme β€” becomes a dependency automatically
var document: Document

window.document returns a reference to the document contained in the window.

MDN Reference

document
.
Document.body: HTMLElement

The Document.body property represents the or node of the current document, or null if no such element exists.

MDN Reference

body
.
HTMLOrSVGElement.dataset: DOMStringMap
dataset
.
DOMStringMap[string]: string | undefined
theme
=
const theme: () => string (+1 overload)
theme
();
});
const theme: (value: string) => void (+1 overload)
theme
("dark"); // body.dataset.theme = "dark"
const stop: () => void
stop
(); // Unsubscribe β€” no more updates

Effects track dependencies dynamically β€” only the signals actually read during the last run are tracked.

effectScope

Groups multiple effects under a single lifecycle. Calling the returned stop function disposes all effects at once.

import {
function signal<T>(): Updater<T> & Computed<T> (+1 overload)

Creates a mutable reactive signal.

  • Read: call with no arguments β†’ returns the current value and subscribes the active tracking context.
  • Write: call with a value β†’ updates the signal and schedules downstream effects if the value changed.

@example

const count = signal(0);
count(); // β†’ 0 (read)
count(1); // write – effects depending on count will re-run
count(); // β†’ 1

signal
,
function effect(fn: () => void): () => void

Creates a reactive side-effect that runs immediately and re-runs whenever any signal or computed it read during its last execution changes.

Use

onCleanup

inside fn to register teardown logic that runs before each re-execution and on final disposal.

If effect is called inside an effectScope or another effect, the new effect is automatically owned by the outer scope and will be disposed when the scope is disposed.

@param ― fn - The side-effect body. Reactive reads inside this function establish dependency links.

@returns ― A disposal function. Call it to stop the effect and run any registered cleanup.

@example

const url = signal('/api/data');
const stop = effect(() => {
const controller = new AbortController();
fetch(url(), { signal: controller.signal });
onCleanup(() => controller.abort());
});
url('/api/other'); // previous fetch is aborted, new one starts
stop(); // final cleanup: abort the last fetch

effect
,
function effectScope(fn: () => void): () => void

Creates an ownership scope that groups reactive effects so they can all be disposed at once.

Effects and nested scopes created inside fn are linked to this scope. When the returned disposal function is called, all owned effects are stopped in cascade – triggering their registered

onCleanup

callbacks – and the scope itself is removed from any parent scope that owns it.

@param ― fn - Synchronous setup function. Create effects and nested scopes here.

@returns ― A disposal function that tears down all owned effects and the scope itself.

@example

const stopAll = effectScope(() => {
effect(() => console.log('a:', a()));
effect(() => console.log('b:', b()));
});
stopAll(); // both effects stopped simultaneously

effectScope
} from "elements-kit/signals";
const
const user: Updater<string> & Computed<string>
user
=
signal<string>(initialValue: string): Updater<string> & Computed<string> (+1 overload)

Creates a mutable reactive signal.

  • Read: call with no arguments β†’ returns the current value and subscribes the active tracking context.
  • Write: call with a value β†’ updates the signal and schedules downstream effects if the value changed.

@example

const count = signal(0);
count(); // β†’ 0 (read)
count(1); // write – effects depending on count will re-run
count(); // β†’ 1

signal
("Alice");
const
const page: Updater<string> & Computed<string>
page
=
signal<string>(initialValue: string): Updater<string> & Computed<string> (+1 overload)

Creates a mutable reactive signal.

  • Read: call with no arguments β†’ returns the current value and subscribes the active tracking context.
  • Write: call with a value β†’ updates the signal and schedules downstream effects if the value changed.

@example

const count = signal(0);
count(); // β†’ 0 (read)
count(1); // write – effects depending on count will re-run
count(); // β†’ 1

signal
("/home");
// All effects inside are grouped together
const stop =
function effectScope(fn: () => void): () => void

Creates an ownership scope that groups reactive effects so they can all be disposed at once.

Effects and nested scopes created inside fn are linked to this scope. When the returned disposal function is called, all owned effects are stopped in cascade – triggering their registered

onCleanup

callbacks – and the scope itself is removed from any parent scope that owns it.

@param ― fn - Synchronous setup function. Create effects and nested scopes here.

@returns ― A disposal function that tears down all owned effects and the scope itself.

@example

const stopAll = effectScope(() => {
effect(() => console.log('a:', a()));
effect(() => console.log('b:', b()));
});
stopAll(); // both effects stopped simultaneously

effectScope
(() => {
const stop: () => void
function effect(fn: () => void): () => void

Creates a reactive side-effect that runs immediately and re-runs whenever any signal or computed it read during its last execution changes.

Use

onCleanup

inside fn to register teardown logic that runs before each re-execution and on final disposal.

If effect is called inside an effectScope or another effect, the new effect is automatically owned by the outer scope and will be disposed when the scope is disposed.

@param ― fn - The side-effect body. Reactive reads inside this function establish dependency links.

@returns ― A disposal function. Call it to stop the effect and run any registered cleanup.

@example

const url = signal('/api/data');
const stop = effect(() => {
const controller = new AbortController();
fetch(url(), { signal: controller.signal });
onCleanup(() => controller.abort());
});
url('/api/other'); // previous fetch is aborted, new one starts
stop(); // final cleanup: abort the last fetch

effect
(() =>
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
("user:",
const user: () => string (+1 overload)
user
()));
function effect(fn: () => void): () => void

Creates a reactive side-effect that runs immediately and re-runs whenever any signal or computed it read during its last execution changes.

Use

onCleanup

inside fn to register teardown logic that runs before each re-execution and on final disposal.

If effect is called inside an effectScope or another effect, the new effect is automatically owned by the outer scope and will be disposed when the scope is disposed.

@param ― fn - The side-effect body. Reactive reads inside this function establish dependency links.

@returns ― A disposal function. Call it to stop the effect and run any registered cleanup.

@example

const url = signal('/api/data');
const stop = effect(() => {
const controller = new AbortController();
fetch(url(), { signal: controller.signal });
onCleanup(() => controller.abort());
});
url('/api/other'); // previous fetch is aborted, new one starts
stop(); // final cleanup: abort the last fetch

effect
(() =>
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
("page:",
const page: () => string (+1 overload)
page
()));
});
const user: (value: string) => void (+1 overload)
user
("Bob"); // β†’ user: Bob
const page: (value: string) => void (+1 overload)
page
("/about"); // β†’ page: /about
const stop: () => void
stop
(); // 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.


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.


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)

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

@reactive decorator

Backs a class field with a signal transparently. The field reads and writes naturally β€” no () needed on the consumer side.

import {
function reactive<This extends object, Value>(source?: (self: This) => Signal<Value>): (_target: unknown, context: ClassFieldDecoratorContext<This, Value>) => (this: This, initialValue: Value) => Value

A decorator that makes a class field reactive by automatically wrapping its value in a signal.

The field behaves like a normal property (get/set) but reactivity is tracked under the hood. Any reads will subscribe to the signal and any writes will trigger updates.

@example

class Counter {
\@reactive() count: number = 0;
}
const counter = new Counter();
counter.count++; // Triggers reactivity
console.log(counter.count); // Subscribes to changes

@remarks ―

Equivalent to manually creating a private signal and getter/setter:

class Counter {
#count = signal(0);
get count() { return this.#count(); }
set count(value) { this.#count(value); }
}

reactive
,
function computed<T>(getter: (previousValue?: T) => T): () => T

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.

@param ― getter - 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)

computed
} from "elements-kit/signals";
class
class Counter
Counter
{
@
reactive<object, unknown>(source?: ((self: object) => Signal<unknown>) | undefined): (_target: unknown, context: ClassFieldDecoratorContext<object, unknown>) => (this: object, initialValue: unknown) => unknown

A decorator that makes a class field reactive by automatically wrapping its value in a signal.

The field behaves like a normal property (get/set) but reactivity is tracked under the hood. Any reads will subscribe to the signal and any writes will trigger updates.

@example

class Counter {
\@reactive() count: number = 0;
}
const counter = new Counter();
counter.count++; // Triggers reactivity
console.log(counter.count); // Subscribes to changes

@remarks ―

Equivalent to manually creating a private signal and getter/setter:

class Counter {
#count = signal(0);
get count() { return this.#count(); }
set count(value) { this.#count(value); }
}

reactive
()
Counter.count: number
count
= 0;
Counter.doubled: () => number
doubled
= computed(() => this.
Counter.count: number
count
* 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.

@param ― getter - 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
const c: Counter
c
= new
constructor Counter(): Counter
Counter
();
const c: Counter
c
.
Counter.count: number
count
= 5;
const c: Counter
c
.
Counter.doubled: () => number
doubled
(); // β†’ 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;
}

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.