Skip to content

Signals are Whisq’s reactive primitives. They hold values that automatically track dependencies and trigger updates when changed.

import { signal } from "@whisq/core";
const count = signal(0);
count.value; // 0 — read (triggers dependency tracking)
count.value = 5; // write (triggers updates)
count.update(n => n + 1); // 6 — update via function
count.peek(); // 6 — read WITHOUT tracking
count.set(10); // 10 — alias for direct assignment

Use peek() inside effects when you need to read a value without creating a dependency:

effect(() => {
// Re-runs when `trigger` changes, but NOT when `config` changes
console.log(trigger.value, config.peek());
});

computed() creates a read-only signal that auto-updates when its dependencies change:

import { signal, computed } from "@whisq/core";
const firstName = signal("Ada");
const lastName = signal("Lovelace");
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
fullName.value; // "Ada Lovelace"
firstName.value = "Grace";
fullName.value; // "Grace Lovelace" — auto-updated

Computed values are lazy — they don’t recompute until read. They also cache — reading twice without dependency changes returns the cached value.

effect() runs a function immediately and re-runs it whenever its dependencies change:

import { signal, effect } from "@whisq/core";
const count = signal(0);
const dispose = effect(() => {
console.log(`Count is: ${count.value}`);
});
// Logs: "Count is: 0"
count.value = 1;
// Logs: "Count is: 1"
dispose(); // Stop watching
count.value = 2; // No log — effect is disposed

Return a function from an effect to run cleanup before each re-execution:

effect(() => {
const timer = setInterval(() => tick(), 1000);
return () => clearInterval(timer); // cleanup
});

Effects only track signals read in the current execution:

const flag = signal(true);
const a = signal("A");
const b = signal("B");
effect(() => {
// When flag is true, tracks `a` only
// When flag is false, tracks `b` only
console.log(flag.value ? a.value : b.value);
});

batch() defers effect re-runs until all updates complete:

import { signal, effect, batch } from "@whisq/core";
const x = signal(0);
const y = signal(0);
effect(() => {
console.log(`${x.value}, ${y.value}`);
});
// Logs: "0, 0"
batch(() => {
x.value = 1;
y.value = 2;
});
// Logs: "1, 2" — only ONCE, not twice

Without batch(), the effect would run once for x and again for y.

For integrating with external systems:

const count = signal(0);
const unsub = count.subscribe(value => {
// Called immediately with current value, then on every change
externalSystem.update(value);
});
unsub(); // Stop subscribing

Two () => T shapes come up constantly in Whisq prose and code. They look structurally identical but fill different roles — which matters because it controls whether you need to write the function or whether the framework hands it to you:

  • Getter — a () => value closure you write around a read to make it reactive. Whisq evaluates it lazily whenever the surrounding position needs to re-render. You’ll see getters in reactive element children, reactive props, computed bodies, effect bodies, rcx() args, and anywhere the docs say “pass a getter.”

    span(() => count.value) // getter child
    div({ class: () => active.value ? "on" : "off" }) // getter prop
    rcx("btn", () => loading.value && "btn-loading") // getter arg
  • Accessor — a () => T function the framework hands to you. You call it (with parentheses) to read the current value. The framework owns a backing signal; when it updates, anything that reads the accessor inside a getter or reactive position sees the fresh value. You’ll see accessors on keyed each() callbacks (todo(), index()) and on resource() fields (users.data(), users.loading(), users.error()).

    each(() => todos.value, (todo) => li(todo().text), { key: (t) => t.id })
    // ↑ `todo` is an accessor — call it to read the current entry
    // `key` receives plain T, not an accessor
    const users = resource(() => fetch("/api/users").then(r => r.json()));
    users.data() // accessor — returns current data or undefined
    users.loading() // accessor — true while fetching

Rule of thumb: “who wrote the function?” User-written → getter. Framework-supplied → accessor. When you hear “wrap it in a getter” it means you’re writing a closure; when you hear “call the accessor” it means the framework gave you the function and you call it.

Edge case — rcx(). rcx() is framework-supplied and returns a () => string that lives in a reactive class position. Its return value is passed to a getter-shaped slot, so the docs describe it as a “getter function” at the call site even though the framework produced it. The distinction only matters when the framework gives you a function and tells you to call it to read a value — that’s the accessor pattern. If the framework gives you a function that the framework itself invokes at a reactive position, it behaves as a getter.

Signals (.value) and plain values are the other two shapes that show up in reactive positions. See the Reactive shapes cheat sheet for all four.

  • How Reactivity Works — The diagram + five questions for the signal → subscriber → DOM update flow.
  • Elements — Use signals in reactive UI elements
  • Components — Encapsulate signals in components

Docs current to v0.1.0-alpha.9 . All releases →