[]previous and next page

Reactivity model

Signals, memos, effects, derived constants and stores as one dependency graph, and the exact rules for what is tracked.

For agentsUpdated

ShadowJS has one dependency graph. Every reactive value is a node in it, and there are only three kinds of node.

Node Created by Role
Source useStore cells and store properties (one source per property that is read), useResource internals holds a value, notifies readers when it changes
Memo useMemo, compiler-derived constants (const double = count() * 2) reads sources, caches its result, is itself a source
Effect useEffect, useRenderEffect, onMount, every DOM binding the compiler emits reads sources, performs a side effect, is never read

Nothing else is reactive. A component is an ordinary function that runs once; only the memos and effects it creates (and the expressions the compiler turns into effects) run again.

Tracking: what a read does#

A read subscribes the code that is currently running, but only when that code runs inside a tracking scope. Reading count() anywhere else returns the value and subscribes nothing.

Where tracking is on, where it is off#

Tracked (reads subscribe) Untracked (reads never subscribe)
The body of useMemo(fn) The body of a component (it runs once)
The body of useEffect(fn) (single-function form) useEffect(deps, fn): fn runs untracked; only deps is tracked
The compute of useRenderEffect(compute, apply) The apply of useRenderEffect
JSX expressions: {count()}, class={cls()}, style={...} onMount(fn)
The each of For / the list of mapArray Event handlers (onClick, onInput, ...)
The source of useResource(source, fetcher) Row callbacks of For and mapArray, resource fetchers
createRoot(fn), runWithOwner(owner, fn), cleanups, ref and use: directives
Setter updater functions (setCount((n) => n + 1))

This is the rule behind "user callbacks run untracked unless the API says otherwise". It is why writing setCount((n) => n + 1) inside an effect does not make the effect depend on count, and why a component that logs in its body logs once.

Dependencies are discovered on every run#

A memo or effect re-collects its dependencies each time it runs. A branch that was not taken is not a dependency.

Equality and updaters#

A write notifies only if the new value differs by the signal's equality: Object.is by default. { equals: false } makes every write a change; a function (a, b) => boolean customizes it. The same option exists on useMemo.

The setter takes a value or an updater (previous) => next. A signal holds a function as a value only through the updater form: setHandler(() => fn).

Stores: one source per property#

useStore(plainData) returns a read-only view and a setter. Each property (and each array index, and length) is a separate source, created lazily the first time a tracking scope reads it. An update notifies exactly the readers of what changed.

unwrap(store) and snapshot(store) read raw data without subscribing. Use them for identity checks and for copies that must not change later. Class instances, Date, Map and Set are stored as single values, not wrapped. Everything else about stores is in state.

Derived constants are memos#

Inside a component, a top-level const that reads a signal is rewritten by the compiler into a memo. You can write it as a value or call it; both stay live. The compiler output shows the rewrite:

src/derive-output.tsts
import { compile } from "@shadow-js/compiler";

const source = `import { useStore } from "@shadow-js/core";
export function Counter() {
  const [count] = useStore(1);
  const double = count() * 2;
  return <p>{count()} x2 = {double}</p>;
}`;

const { code } = compile(source, { filename: "counter.tsx" });
console.log(code.includes("const double = _$derived(() => (count() * 2));"));
console.log(code.includes("() => double.value"));
Expected output
true
true

The rules for which constants are rewritten (only top-level const in a component, never hooks or functions) are normative in forgiving reactivity. A derived constant behaves like useMemo: it caches, it re-runs only when a signal it read changed, and it is disposed with its owner.

A component runs once#

Because component bodies are untracked, the body executes a single time. Reads in the body produce a value, not a subscription. Reads inside JSX expressions are subscriptions: the compiler wraps them in effects that update the DOM node in place.

Memos are pure#

A memo must not write signals. In development, writing a signal or store while a memo computes throws WRITE_IN_MEMO. A memo that reads itself, directly or through other memos, throws CYCLE_DETECTED to its readers. Both are explained, with fixes, in the diagnostics reference.

A memo that throws does not throw at creation: it stores the error and rethrows it to every reader, which is how WRITE_IN_MEMO surfaces.

Related: scheduler (when effects run), ownership (when nodes are disposed), useStore, useMemo, useEffect, useStore, untrack.