pyreon

@pyreon/reactivity — API Reference

Generated from reactivity's src/manifest.ts — the same source that powers llms.txt and MCP get_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see reactivity.

Standalone reactive primitives — no DOM, no JSX, no framework dependency. Signals are callable functions (count() to read, count.set(5) to write, count.update(n => n + 1) to derive). Direct subscribers use a single-subscriber inline slot (_d1) promoting to a Set only on the 2nd subscriber (a 10k-row <For> allocates zero subscriber Sets); effect subscribers use a lazy Set; batch uses pointer swap for zero-allocation grouping. Every other Pyreon package builds on this foundation but @pyreon/reactivity can be used independently in Node, Bun, or browser scripts without any framework overhead. wrapSignal(base, { set, update? }) builds a writable side-effect facade (persistence, patch emission, validation) that forwards the internal _v field and .direct by construction, so the bind-fast-path contract cannot be silently broken.

Multiplatform

Tier: Shared — the same source runs on web, iOS and Android

L0 of the shared-code model: the package plugin maps signal/computed to native state; PyreonReactivity runtime ports ship on both targets, while bare effect statements require a supported lifecycle hook

See Multiplatform for the capability matrix and Multiplatform libraries for every package's tier.

Features

  • Owns its native compiler frontend through the optional ./native-plugin tooling entry; the native CLI discovers it from package metadata

  • signal<T>() — callable function with .set(), .update(), .trigger()

  • computed<T>() — auto-tracked derivation; caches its value, but notifies downstream on every dependency change unless you pass equals

  • effect() / renderEffect() — side-effects with auto-tracking

  • batch() / nextTick() — write-grouping + flush awaiter

  • onCleanup() — register cleanup with the current owner (effect run, component setup, or runInScope/onMount)

  • watch(source, callback) — explicit reactive watcher

  • createSelector() — O(1) equality selector for keyed lists

  • cell<T>() — lighter alternative to signal() for direct subscribe()

  • createStore() / reconcile() / isStore() — deeply reactive proxy stores + structural diff

  • effectScope() / getCurrentScope() — scope-based lifecycle management

  • untrack() — read without subscribing

  • onSignalUpdate() / inspectSignal() / why() / getReactiveTrace() — debug instrumentation

  • setErrorHandler() — global hook for unhandled effect errors

  • Standalone — zero DOM, zero JSX, zero framework dependency

  • wrapSignal(base, { set, update? }) — writable side-effect facade (persistence / patch emission); forwards _v + .direct by construction

Complete example

A full, end-to-end usage of the package:

import { signal, computed, effect, batch, onCleanup, createStore, watch, untrack } from "@pyreon/reactivity"

// signal<T>() — callable function, NOT .value getter/setter
const count = signal(0)
count()              // read (subscribes)
count.set(5)         // write
count.update(n => n + 1)  // derive
count.peek()         // read WITHOUT subscribing

// computed<T>() — auto-tracked; caches its value, notifies unconditionally (see equals)
const doubled = computed(() => count() * 2)

// effect() — re-runs when dependencies change
const dispose = effect(() => {
  console.log("Count:", count())
  onCleanup(() => console.log("cleaning up"))
})

// batch() — group 3+ writes into a single notification pass
batch(() => {
  count.set(10)
  count.set(20)  // subscribers fire once, with 20
})

// watch(source, callback) — explicit dependency tracking
watch(() => count(), (next, prev) => {
  console.log(`changed from ${prev} to ${next}`)
})

// createStore() — deeply reactive object (proxy-based)
const store = createStore({ todos: [{ text: 'Learn Pyreon', done: false }] })
store.todos[0].done = true  // fine-grained update, no immer needed

// untrack() — read signals without subscribing
effect(() => {
  const current = count()
  const other = untrack(() => otherSignal())  // won't re-run when otherSignal changes
})

Exports

SymbolKindSummary
signalfunctionCreate a reactive signal.
isServerconstantCanonical runtime environment flag — true when there is no DOM (typeof document === 'undefined'), i.e.
isClientconstantInverse of isServer — true on a browser main thread where a DOM is available (typeof document !== 'undefined').
computedfunctionCreate a derived value that caches its result and recomputes lazily.
effectfunctionRun a side effect that auto-tracks signal dependencies and re-runs when they change.
renderEffectfunctionDOM-specific effect with a lighter dependency tracking path — uses a local array for deps instead of the full `EffectSco
batchfunctionGroup multiple signal writes so subscribers fire only once — after the batch completes.
nextTickfunctionReturns a promise that resolves after the next microtask.
onCleanupfunctionRegister a cleanup with the CURRENT OWNER.
watchfunctionExplicit reactive watcher — tracks source and fires callback when it changes.
createSelectorfunctionCreate an O(1) equality selector — returns a reactive predicate that fires only when the previously-selected and newly-s
cellfunctionLightweight reactive primitive — class-based alternative to signal().
createStorefunctionCreate a deeply reactive proxy-based object.
createResourcefunctionAsync data primitive.
reconcilefunctionSurgically diff a new value into an existing createStore proxy.
isStorefunctionType guard — returns true if the value is a createStore proxy (recognized via an internal symbol marker).
shallowReactivefunctionCreate a SHALLOW reactive store — only top-level mutations trigger updates.
markRawfunctionMark an object as RAW — createStore and shallowReactive will return it unwrapped.
untrackfunctionExecute a function reading signals WITHOUT subscribing to them.
effectScopefunctionCreate an EffectScope — a container that auto-tracks effects/computeds created inside scope.runInScope(fn) and dispo
onScopeDisposefunctionRegister a callback to run when the current EffectScope stops.
getCurrentScopefunctionReturns the currently active EffectScope (the one whose runInScope(fn) is on the stack), or null if no scope is ac
setCurrentScopefunctionLow-level escape hatch — directly set the ambient EffectScope.
onSignalUpdatefunctionRegister a global trace listener that fires on every signal write.
inspectSignalfunctionInspect a signal — pretty-prints its current value, name, and subscriber count to the console (in a console.group) and
whyfunctionToggle a global "why-did-it-update?" tracer that logs every signal write between consecutive calls.
getReactiveTracefunctionReturns the last ~50 signal writes (chronological, oldest → newest) from a bounded dev-only ring buffer — the causal SEQ
setErrorHandlerfunctionRegister a global handler for unhandled errors thrown inside effect() / computed() / renderEffect().
activateReactiveDevtoolsfunctionOpt-in lifecycle for the reactive-devtools bridge — the live signal/computed/effect graph the @pyreon/devtools Signals
getReactiveGraphfunctionFresh snapshot of the live reactive graph + a bounded recent-fire timeline, for the reactive-devtools tabs.
describeReactiveGraphfunctionAuto-generated BEHAVIORAL description of the reactive graph — what a change to each signal actually DOES, in English, pl
getUpdateCausefunctionAnswers "why did this node just update?" at the SOURCE LINE, along the exact causal chain — the thing React DevTools' wh
wrapSignalfunctionCreate a signal facade over a base signal with custom write behavior.
WrapSignalOptionstypeConfiguration object for wrapSignal().
startReactiveCoveragefunctionBegin a Reactive Coverage session (from the @pyreon/reactivity/coverage subpath).
takeReactiveCoveragefunctionSnapshot the current Reactive Coverage session into a ReactiveCoverageReport — `{ total, covered, uncovered, percent,
formatReactiveCoveragefunctionRender a ReactiveCoverageReport as a dependency-free, human-readable text block: a headline (`Reactive Coverage — 42.9
SignalValuetypeUnwrap the VALUE type of a Signal<T>, Computed<T>, ReadonlySignal<T>, or any zero-arg accessor — `SignalValue<type
ComputedValuetypeUnwrap the value type of a Computed<T> — intent-revealing alias of SignalValue (every Pyreon reactive read is a zero
MaybeAccessortypeThe standard "static value OR reactive accessor" parameter shape used across Pyreon APIs (<Show when>, hook options).
AccessorReturntypeResolve a MaybeAccessor (or any accessor) to its VALUE type — unwraps the () => T arm and passes plain values throug
registerSingletonfunctionFail-loud detection of duplicate framework instances.
defineCrossModuleStatefunctionA globalThis-keyed singleton, for state that must be shared even across a genuine dual-instance situation (as opposed to
getContextOwnerfunctionRead the currently active CONTEXT OWNER — the EffectScope that @pyreon/core's provide()/useContext() resolve aga
runWithContextOwnerfunctionRun fn with owner as the active context owner, then restore whatever was active before — the safe, try/finally-guard
setContextOwnerfunctionLow-level escape hatch — directly set the active context owner, returning the PREVIOUS one (for a manual restore).

API

signal function

<T>(initialValue: T, options?: { name?: string }) => Signal<T>

Create a reactive signal. The returned value is a CALLABLE FUNCTION — count() reads (and subscribes), count.set(v) writes, count.update(fn) derives, count.peek() reads without subscribing. This is NOT a .value getter/setter pattern (React/Vue) — Pyreon signals are functions. Writes gate on Object.is, so set(sameReference) is a no-op; when you hold a MUTABLE value and mutate it in place, count.trigger() force-notifies subscribers without a value change (Vue triggerRef semantic). Optional { name } for debugging; auto-injected by @pyreon/vite-plugin in dev mode.

Parameters

ParameterTypeDescription
initialValueTThe signal's initial value.
options?{ name?: string }Optional debug name (auto-injected by @pyreon/vite-plugin in dev mode).

Returns Signal<T> — A callable signal — call it to read (and subscribe); .set(v) / .update(fn) to write; .trigger() to force-notify after an in-place mutation; .peek() to read without subscribing.

Example

const count = signal(0)
count()              // 0 (subscribes to updates)
count.set(5)         // sets to 5
count.update(n => n + 1)  // 6
count.peek()         // 6 (does NOT subscribe)

// Mutable value held in a signal:
const items = signal(new Map())
items.peek().set('a', 1)  // mutate in place — set(sameRef) would be a no-op
items.trigger()           // force subscribers to re-run

Common mistakes

  • count.value — does not exist. Use count() to read

  • count = 5 — reassigning the variable replaces the signal, does not write to it. Use count.set(5)

  • signal(5) called with an argument after creation — reads and ignores the argument (dev mode warns). Use .set(5) to write

  • const [val, setVal] = signal(0) — signals are not destructurable tuples. The whole return value IS the signal

  • {count} in JSX — renders the signal function itself, not its value. Use {count()} or {() => count()}

  • .peek() inside effect() / computed() — bypasses tracking, creates stale reads. Only use .peek() for loop-prevention guards

  • Mutating a held object in place then set(sameReference) — a no-op (Object.is gate), subscribers never re-run. Prefer an immutable set(newObject); if you deliberately own a mutable value, mutate then .trigger()

See also: computed · effect · batch


isServer constant

const isServer: boolean

Canonical runtime environment flag — true when there is no DOM (typeof document === 'undefined'), i.e. during SSR / in a Node or edge worker. typeof document is the reliable "is there a DOM" discriminator; it is more correct than typeof window, which misreports Deno and polyfilled-Node setups. A plain constant evaluated once at module load — correct in every runtime with zero bundler configuration. Use it for small environment guards (module-level singletons, lazy globals, render output that differs server vs client). isClient is its inverse.

Example

import { isServer } from '@pyreon/reactivity'
if (isServer) return // bail on the server
window.addEventListener('resize', onResize)

Common mistakes

  • Rolling your own const isBrowser = typeof window !== 'undefined' instead — typeof window misreports Deno / polyfilled Node; import isServer / isClient

  • Reaching for isClient to gate DOM access inside a component — prefer onMount / effect, which never run during SSR

  • Putting heavy server-only code behind isServer in a client-imported module — it still ships to the client bundle; use a /server subpath export instead

See also: isClient


isClient constant

const isClient: boolean

Inverse of isServer — true on a browser main thread where a DOM is available (typeof document !== 'undefined'). A plain constant evaluated once at module load. Use it for small environment guards; for DOM access inside a component prefer onMount / effect (which never run during SSR).

Example

import { isClient } from '@pyreon/reactivity'
const initial = isClient ? navigator.onLine : true

Common mistakes

  • Rolling your own const isBrowser = typeof window !== 'undefined' instead — import isClient

  • Using isClient to defer DOM work that belongs in onMount / effect

See also: isServer


computed function

<T>(fn: () => T, options?: { equals?: (a: T, b: T) => boolean }) => Computed<T>

Create a derived value that caches its result and recomputes lazily. Dependencies auto-tracked on each evaluation — no dependency array needed (unlike React useMemo). Only recomputes when a tracked signal actually changes. By DEFAULT a computed notifies downstream on every dependency change, even when the recomputed value is unchanged — there is no implicit equality check (a signal gates on Object.is; a computed does not). This is the one place Pyreon diverges from Solid createMemo / Vue + Preact computed, which all gate on equality by default; if you are porting from those, an unchanged result does NOT stop propagation here. Pass equals to gate on value, which is what stops an effect re-running on an identical result.

Parameters

ParameterTypeDescription
fn() => TDerivation — reads other signals/computeds; dependencies are auto-tracked on each run.
options?{ equals?: (a: T, b: T) => boolean }Custom equality gate. WITHOUT it a computed notifies downstream on every dependency change, even when the recomputed value is identical — there is NO default equality check (unlike a signal, which does gate on Object.is). Pass equals to suppress those updates. NOTE the trade: equals also switches the computed from LAZY to EAGER, because gating requires knowing the new value at notification time. With a live subscriber that costs nothing (it would have evaluated anyway), but a computed that is read imperatively and never subscribed goes from ZERO evaluations to one per dependency change. So gate computeds with a CHEAP body (n > 0, arr.length, x !== undefined) and leave expensive ones alone — gating computed(() => walkEntireDocument(doc())) buys a suppressed notification and pays a full walk on every keystroke.

Returns Computed<T> — A read-only callable — call to read the cached value; recomputes lazily only when a dependency changes. Caching is about RECOMPUTATION, not propagation: without equals it still notifies downstream even when the recomputed value is identical.

Example

const count = signal(0)
const doubled = computed(() => count() * 2)
doubled()  // 0
count.set(5)
doubled()  // 10

Common mistakes

  • computed(() => count) — must CALL the signal: computed(() => count())

  • Using computed() for side effects — use effect() instead; computed is for pure derivation

  • Expecting computed() to re-run when a .peek()-read signal changes — .peek() bypasses tracking

  • Expecting eager evaluation — the default computed is LAZY: a dependency change only marks it dirty; the derivation runs on the next read. Pass options.equals for the eager variant that re-evaluates on notification and gates downstream updates

  • Assuming computed() de-duplicates identical results — it does NOT. A signal write gates on Object.is, so set(same) is a no-op; a COMPUTED has no such gate, and every dependency change notifies downstream even when the derived value is byte-identical. computed(() => items().length) re-runs its effects on every item mutation that leaves the length alone. Pass { equals } (Object.is if that is what you meant) to gate on value

  • Reasoning about memory from stale branches — a re-evaluated computed drops subscriptions to sources it no longer reads (exact dep list per evaluation), so dispose() fully unsubscribes even after conditional-branch flips

See also: signal · effect


effect function

(fn: () => (() => void) | void) => () => void

Run a side effect that auto-tracks signal dependencies and re-runs when they change. Returns a dispose function that unsubscribes. The effect function can return a cleanup callback (equivalent to calling onCleanup() inside the body) — the cleanup runs before each re-execution and on final dispose. For DOM-specific effects with lighter overhead, use renderEffect() instead.

Parameters

ParameterTypeDescription
fn() => (() => void) | voidEffect body — auto-tracks the signals it reads; may return a cleanup callback run before each re-run and on dispose.

Returns () => void — A dispose function — call it to stop the effect and run its final cleanup.

Example

const count = signal(0)
const dispose = effect(() => {
  console.log("Count:", count())
  onCleanup(() => console.log("cleaning up"))
})
// Or return cleanup directly:
effect(() => {
  const handler = () => console.log(count())
  window.addEventListener("resize", handler)
  return () => window.removeEventListener("resize", handler)
})

Common mistakes

  • Passing a dependency array — Pyreon auto-tracks; no array needed

  • effect(() => { count }) — must call the signal: effect(() => { count() })

  • Nesting effect() inside effect() — use computed() for derived values instead

  • Creating signals inside an effect — they re-create on every run; create once outside

  • Passing an async function — only signal reads BEFORE the first await are tracked (dev mode warns at registration); read every tracked signal before awaiting, or use watch(source, asyncCb)

  • Assuming a conditional read (cond() ? a() : b()) keeps BOTH branches subscribed — only the branch actually read this run is a dependency; the effect re-runs when the read branch or the condition changes, then re-tracks (stale-branch subscriptions are dropped on the next run)

See also: onCleanup · computed · renderEffect


renderEffect function

(fn: () => void) => () => void

DOM-specific effect with a lighter dependency tracking path — uses a local array for deps instead of the full EffectScope integration. Used internally by _bind / _tpl for compiled-template DOM updates. Prefer effect() for general use; reach for renderEffect() only when you're hand-writing DOM update logic and have measured the overhead difference. Returns a dispose function (not an Effect object — different shape from effect()).

Parameters

ParameterTypeDescription
fn() => voidDOM-update body — auto-tracks the signals it reads; lighter than effect (no cleanup-return).

Returns () => void — A dispose function — call it to stop the effect.

Example

// Inside a custom DOM helper that updates a text node:
const node = document.createTextNode('')
const dispose = renderEffect(() => {
  node.data = String(count())
})
// Re-runs only when count() changes; lighter than effect() but no
// onCleanup support and no error-handler routing (it DOES auto-register
// its disposer with the surrounding EffectScope, like effect()).

Common mistakes

  • Calling onCleanup() inside renderEffect() — not supported; only effect() collects cleanups. Use effect() if you need cleanup callbacks

  • Assuming renderEffect() supports the full effect() surface — it auto-registers its DISPOSER with the surrounding EffectScope (tears down on unmount like effect()), but has no onCleanup collection, no error-handler routing, and returns a bare dispose function, not an Effect object

  • Reaching for renderEffect() as the default — effect() is the canonical primitive. The performance delta only matters in extreme hot paths (1000+ DOM nodes), never in component-level code

See also: effect · computed


batch function

(fn: () => void) => void

Group multiple signal writes so subscribers fire only once — after the batch completes. Uses pointer swap (zero allocation). Essential when updating 3+ signals that downstream effects read together; without batch, each .set() triggers an independent notification pass.

Parameters

ParameterTypeDescription
fn() => voidBody whose signal writes are coalesced — subscribers run once at the end, not per write.

Returns void — Nothing — runs fn synchronously and flushes a single update pass.

Example

const a = signal(1)
const b = signal(2)
batch(() => {
  a.set(10)
  b.set(20)
})
// Effects that read both a() and b() fire once, not twice

Common mistakes

  • Reading a signal inside batch() and expecting the NEW value before the batch completes — reads inside the batch see the new value (writes are synchronous), but effects fire only after the batch callback returns

  • Forgetting batch() when updating 3+ related signals — causes N intermediate re-renders

See also: signal · effect


nextTick function

() => Promise<void>

Returns a promise that resolves after the next microtask. Use to await pending reactive updates — every signal write that happens before nextTick() is fully flushed (effects ran, computeds settled, DOM patched) by the time the promise resolves. Equivalent to Vue's nextTick. Useful in tests and in code that needs to read the post-update DOM state.

Returns Promise<void> — Resolves after the current batch of reactive updates has flushed — await it to read post-update DOM/state.

Example

count.set(5)
// Effects haven't run yet (sync writes are queued)
await nextTick()
// Now everything is flushed — DOM reflects count = 5
expect(node.textContent).toBe('5')

Common mistakes

  • Awaiting nextTick() inside a batch() callback — pointless; the batch flushes when the callback returns, not when the microtask drains. Move the await outside batch()

  • Using nextTick() to defer work — it doesn't schedule anything; it just resolves on the next microtask. Use setTimeout / requestAnimationFrame for actual deferral

See also: batch


onCleanup function

(fn: () => void) => void

Register a cleanup with the CURRENT OWNER. Inside an effect() run it runs before the next re-run and on dispose (same as returning a function from the effect). In a component body (setup) it runs when that component unmounts. Inside onMount or EffectScope.runInScope() it runs when the owning scope stops. renderEffect() does NOT collect cleanups itself, so one registered there goes to the enclosing owner. With no owner at all (plain module code) the call is a no-op.

Parameters

ParameterTypeDescription
fn() => voidCleanup callback — runs when its owner re-runs (effects) or is torn down (effect dispose, component unmount, scope stop).

Returns void — Nothing — registers the cleanup with the current owner.

Example

// 1. Inside an effect — runs before each re-run and on dispose
effect(() => {
  const handler = () => console.log(count())
  window.addEventListener("resize", handler)
  onCleanup(() => window.removeEventListener("resize", handler))
})

// 2. In a component body — runs when the component unmounts
function Clock() {
  const now = signal(Date.now())
  const id = setInterval(() => now.set(Date.now()), 1000)
  onCleanup(() => clearInterval(id))
  return () => new Date(now()).toLocaleTimeString()
}

Common mistakes

  • Expecting a cleanup registered inside renderEffect() to run on that render effect's re-runs — renderEffect has no collector; the cleanup goes to the enclosing owner (usually the component) and runs on unmount

  • Calling onCleanup in plain module code with no owner — there is nothing to attach it to, so it never runs

  • Assuming a component-body onCleanup runs when a parent <For>/<Show> re-renders — it belongs to the component and runs only when that component unmounts

  • Registering the same teardown with both onCleanup and a returned cleanup in one effect — both run, so the teardown happens twice

See also: effect · effectScope


watch function

<T>(source: () => T, callback: (next: T, prev: T) => void, options?: WatchOptions) => () => void

Explicit reactive watcher — tracks source and fires callback when it changes. Unlike effect(), the callback receives both next and prev values and does NOT auto-track signals read inside the callback body. source is evaluated at setup time to establish tracking; reading browser globals there still fires SSR lint rules. Returns a dispose function.

Parameters

ParameterTypeDescription
source() => TTracked source — its return value is watched for changes.
callback(next: T, prev: T) => voidRuns on change with the new and previous source values.
options?WatchOptionsOptional watch behavior (e.g. running the callback once immediately on setup).

Returns () => void — A dispose function — call it to stop watching.

Example

watch(() => count(), (next, prev) => {
  console.log(`changed from ${prev} to ${next}`)
})

Common mistakes

  • Reading browser globals in the source function — it runs at setup time (not just in mounted context), so no-window-in-ssr fires on window.X there

  • Expecting signals read inside the callback to be tracked — only the source function establishes tracking; the callback is untracked

  • Forgetting to return a cleanup function from the callback — watch honors a returned function as a cleanup that runs before each re-run AND on dispose. Useful for cancelling in-flight requests, clearing timers, or removing listeners attached on the previous run

See also: effect · computed


createSelector function

<T>(source: () => T) => (value: T) => boolean

Create an O(1) equality selector — returns a reactive predicate that fires only when the previously-selected and newly-selected values' subscribers are affected. Unlike a plain () => source() === value (which re-evaluates for every row in a list), this only triggers TWO subscribers per source change (deselected + newly selected) regardless of list size. Critical for keyed-list selection patterns.

Parameters

ParameterTypeDescription
source() => TTracked source producing the currently-selected key (e.g. the selected row id).

Returns (value: T) => boolean — An O(1) membership selector — isSelected(key) is true only for the matching key; only the entering/leaving items re-run on change, not the whole list.

Example

const selectedId = signal<string | null>(null)
const isSelected = createSelector(() => selectedId())

// In each row's render — O(1) selection updates regardless of N rows:
<For each={rows} by={r => r.id}>{row => (
  <li class={isSelected(row.id) ? 'selected' : ''}>
    {row.label}
  </li>
)}</For>

Common mistakes

  • Using a plain () => source() === value in lists — every row subscribes to source; selecting a row notifies ALL N rows (O(N))

  • Calling isSelected outside a reactive scope — returns the current value but doesn't subscribe

  • Using createSelector for non-equality predicates — it's purpose-built for === matching; for ranges or filters, use computed()

See also: signal · computed


cell function

<T>(value: T) => Cell<T>

Lightweight reactive primitive — class-based alternative to signal(). 1 object allocation vs signal()'s ~6 closures, single-listener fast path (no Set allocated when ≤1 subscriber), methods on prototype shared across instances. NOT callable as a getter — does not integrate with effect dependency tracking. Use when you need reactive state but plan to subscribe directly via .subscribe() / .listen(), NOT via effect(). Ideal for keyed-list row labels where the subscription lifetime equals the row's lifetime.

Example

import { cell } from '@pyreon/reactivity'

// Create a cell:
const label = cell('Initial')

// Read (no tracking — read inside an effect does NOT subscribe):
label.peek()             // 'Initial'

// Write:
label.set('Updated')
label.update(s => s + '!')

// Subscribe directly (returns disposer):
const dispose = label.subscribe(() => console.log(label.peek()))

// Fire-and-forget — no disposer (saves 1 closure allocation):
label.listen(() => console.log('changed'))

Common mistakes

  • Using label() to read — Cells are NOT callable. Use label.peek() to read

  • Reading label.peek() inside effect() and expecting tracked re-runs — Cells don't integrate with effect tracking. Use signal() if you need automatic dependency tracking

  • Using cell() for ALL reactive state — only switch from signal() when you've measured allocation pressure (1000+ instances) AND you don't need effect-based subscriptions

See also: signal


createStore function

<T extends object>(initial: T) => T

Create a deeply reactive proxy-based object. Mutations at any depth trigger fine-grained updates — store.todos[0].done = true only re-runs effects that read store.todos[0].done, not effects that read store.todos.length or other items. No immer, no spread-copy, no produce() — just mutate. Works with nested plain objects and arrays. Built-in types with internal slots (Map, Set, WeakMap, WeakSet, Date, RegExp, Promise, Error) are returned raw and are NOT deeply reactive — they fail the Proxy internal-slot check on every method call. Replace the whole field (store.users = new Map(store.users)) to trigger reactivity for these.

Example

const store = createStore({
  todos: [{ text: 'Learn Pyreon', done: false }],
  filter: 'all',
})
store.todos[0].done = true   // fine-grained — only 'done' subscribers fire
store.todos.push({ text: 'Build app', done: false })  // array methods work

Common mistakes

  • Replacing the entire store object — store = { ... } replaces the variable, not the proxy. Mutate properties instead: store.filter = "active"

  • Destructuring store properties at setup — const { filter } = store captures the value once, losing reactivity. Read store.filter inside reactive scopes

  • Using createStore for simple scalar state — use signal() for primitives; createStore adds proxy overhead that only pays off for nested objects

  • Expecting fine-grained reactivity inside Map/Set/Date/RegExp/Promise — these are returned raw because Proxy can't intercept methods that rely on internal slots. Mutating the raw instance (store.users.set(...)) does NOT notify subscribers. Replace the whole field (store.users = new Map(store.users)) to trigger reactivity

See also: signal


createResource function

<T, P>(source: () => P, fetcher: (param: P) => Promise<T>) => Resource<T>

Async data primitive. Auto-fetches whenever source() changes — data, loading, error are signals readable inside effects. Stale-response guarded via internal requestId (typing fast then slow does not flicker old data). refetch() re-runs the fetcher with the current source value. dispose() MUST be called for resources created outside an EffectScope — otherwise the source-tracking effect leaks for the lifetime of the program.

Example

const userId = signal(1)
const user = createResource(
  () => userId(),
  (id) => fetch(`/api/users/${id}`).then(r => r.json()),
)
effect(() => {
  if (user.loading()) return
  if (user.error()) return console.error(user.error())
  console.log(user.data())
})
userId.set(2)            // auto-refetches
user.refetch()           // explicit refetch with current source
user.dispose()           // stop tracking, discard in-flight response

Common mistakes

  • Forgetting dispose() for resources outside an EffectScope — the internal source-tracking effect runs forever, leaking memory and unbounded fetch calls on source changes

  • Calling refetch() after dispose() — silently no-ops; check disposed state on your end if needed

  • Reading data() without checking loading() / error() — undefined values flow through; gate the read on those signals

  • Expecting an in-flight response to update the resource AFTER dispose() — the response is discarded by design (stale-id check), loading may stay frozen at its dispose-time value

  • Reading signals INSIDE the fetcher and expecting tracked re-runs — only source() is tracked; signals read inside fetcher are read once per call without subscription

See also: signal · effect · effectScope


reconcile function

<T extends object>(source: T, target: T) => void

Surgically diff a new value into an existing createStore proxy. Walks both trees in parallel and only calls .set() on signals whose value actually changed — unchanged subtrees do NOT re-run their effects. Ideal for applying API responses to a long-lived store: only the truly-changed fields trigger updates, even if you receive a fully-replacement payload from the server. Arrays reconcile by index; excess elements are removed.

Example

const state = createStore({ user: { name: 'Alice', age: 30 }, items: [] })

// API response arrives — pure replacement payload:
reconcile(
  { user: { name: 'Alice', age: 31 }, items: [{ id: 1 }] },
  state,
)
// → only state.user.age signal fires (name unchanged)
// → state.items[0] is newly created, length signal fires

Common mistakes

  • Passing a non-store as target — reconcile requires a createStore proxy; for plain objects, just assign

  • Expecting reconciliation by key for arrays — arrays are reconciled BY INDEX. For keyed list reconciliation, use a Map keyed by id and reconcile each entry by key, OR replace the array reference (which <For> reconciles via by)

  • Using reconcile inside an effect — it triggers writes; you'd cycle. Call it outside reactive scopes (e.g. in a query callback or event handler)

See also: createStore · signal


isStore function

(value: unknown) => boolean

Type guard — returns true if the value is a createStore proxy (recognized via an internal symbol marker). Use to differentiate reactive stores from plain objects in code that handles both shapes (e.g. helpers that conditionally reconcile() vs assign).

Example

const a = createStore({ x: 1 })
const b = { x: 1 }
isStore(a)  // true
isStore(b)  // false
isStore(null)  // false (null-safe)

Common mistakes

  • Using isStore to detect ANY proxy — it's specific to Pyreon's store proxies. Other proxies return false

  • Calling on null / undefined and expecting a throw — null-safe; returns false

See also: createStore · reconcile


shallowReactive function

<T extends object>(initial: T) => T

Create a SHALLOW reactive store — only top-level mutations trigger updates. Nested objects are NOT auto-wrapped; reading a nested object returns the raw reference, and mutating it does NOT trigger any effect. Replacing the top-level reference DOES trigger reactivity. Use when nested data is immutable (frozen API responses), when you want explicit control over which subtrees are reactive, or when you need to store class instances/third-party objects without paying the deep-proxy overhead. Vue 3 parity.

Example

const store = shallowReactive({ user: { name: 'Alice' }, count: 0 })
effect(() => { store.count })        // tracks store.count
effect(() => { store.user })         // tracks store.user reference (not its contents)
store.user.name = 'Bob'          // does NOT trigger any effect (nested mutation)
store.count = 5                  // triggers count effect
store.user = { name: 'Bob' }     // triggers user effect (reference replacement)

Common mistakes

  • Expecting nested mutations to trigger effects — they don't. Use createStore if you need deep reactivity, or replace the top-level reference (store.user = { ...store.user, name: 'Bob' })

  • Mixing shallow + deep on the same raw object — createStore(raw) and shallowReactive({ wrapper: raw }) produce DIFFERENT proxies (separate caches). Pick one shape per data flow

See also: createStore · markRaw


markRaw function

<T extends object>(value: T) => T

Mark an object as RAW — createStore and shallowReactive will return it unwrapped. Useful for class instances, third-party objects, DOM nodes, or any shape that shouldn't be deeply proxied (Vue 3 parity). Marking is one-way: there's no unmarkRaw. Mark BEFORE the object enters a store; marking after wrap doesn't unwrap an existing proxy.

Example

import { markRaw, createStore } from '@pyreon/reactivity'

class Editor { someMethod() {} }
const ed = markRaw(new Editor())   // skips proxy
const store = createStore({ editor: ed })
store.editor === ed                 // true — raw reference preserved
store.editor.someMethod()           // works — class methods see real receiver

Common mistakes

  • Marking an object AFTER it's been wrapped — the existing proxy is unaffected. Mark before the object enters any store

  • Expecting markRaw(obj) to return a different object — it mutates obj and returns the SAME reference (with the marker symbol attached)

  • Using markRaw on plain data objects to "skip" deep wrap — for that, use shallowReactive. markRaw is for class instances and externally-managed shapes

See also: createStore · shallowReactive


untrack function

(fn: () => T) => T

Execute a function reading signals WITHOUT subscribing to them. Alias for runUntracked. Use inside effects when you need to read a signal's current value as a one-shot snapshot without the effect re-running when that signal changes.

Example

effect(() => {
  const current = count()        // tracked — effect re-runs on count change
  const other = untrack(() => otherSignal())  // NOT tracked — just reads the current value
})

Common mistakes

  • Using untrack as the default — signals should be tracked by default; untrack is the escape hatch for specific optimization or loop-prevention cases

See also: signal · effect


effectScope function

() => EffectScope

Create an EffectScope — a container that auto-tracks effects/computeds created inside scope.runInScope(fn) and disposes them all at once via scope.stop(). @pyreon/core's mountReactive uses this internally for component lifetime management. Always use a scope for effects created outside a component's setup phase (e.g. in event handlers, route loaders, or async-await chains) — without one, effects leak for the lifetime of the program.

Example

import { effectScope, signal, effect } from '@pyreon/reactivity'

const scope = effectScope()
const count = signal(0)

scope.runInScope(() => {
  effect(() => console.log(count()))    // tracked by scope
})

count.set(5)   // logs 5
scope.stop()   // tears down all effects in the scope
count.set(10)  // no log — effect was disposed

Common mistakes

  • Forgetting scope.stop() — effects leak for the lifetime of the program; same shape as forgetting dispose() on a top-level effect()

  • Creating effects outside runInScope(fn) and expecting them to be tracked — effects must run during the synchronous body of runInScope to register with the scope

  • Stopping a scope that has pending updates — in-flight microtasks may still fire onUpdate hooks; design for idempotency or check isActive before writes

See also: effect · getCurrentScope · onScopeDispose


onScopeDispose function

(fn: () => void) => void

Register a callback to run when the current EffectScope stops. Vue 3 parity. Captures the AMBIENT scope at registration time, so it must be called inside scope.runInScope(fn). Calling outside any scope is a no-op (with a dev warning). Use for resource cleanup tied to scope lifetime — timers, listeners, external subscriptions. Equivalent to getCurrentScope()?.add({ dispose: fn }) but without the boilerplate.

Example

scope.runInScope(() => {
  const ws = new WebSocket(url)
  onScopeDispose(() => ws.close())
  // ws.close() runs when scope.stop() is called
})

Common mistakes

  • Calling outside any scope — silently no-ops in production, dev warns. The callback is dropped on the floor; verify with getCurrentScope() before calling if scope is uncertain

  • Expecting the callback to run on EFFECT cleanup — onScopeDispose fires only on scope.stop(). For per-effect cleanup, use onCleanup() inside the effect body or return a cleanup function from it

  • Using outside runInScope and inside an effect callback — the effect captures whatever scope was ambient when the effect SET UP, not when the registration runs. Effects re-run later may see a different ambient scope; register at setup, not in the body

See also: effectScope · getCurrentScope · onCleanup


getCurrentScope function

() => EffectScope | null

Returns the currently active EffectScope (the one whose runInScope(fn) is on the stack), or null if no scope is active. Use to register cleanup with the surrounding scope, or to detect "am I inside a component lifetime?" — useful for library code that wants to register an effect with the consumer's scope rather than the global one.

Example

import { getCurrentScope } from '@pyreon/reactivity'

function myReactiveResource() {
  const scope = getCurrentScope()
  if (scope) {
    // Inside a component — register cleanup with the component's scope
    scope.add({ dispose: cleanup })
  } else {
    // Top-level / standalone — caller must call dispose() manually
    console.warn('myReactiveResource: no active scope; remember to dispose')
  }
}

Common mistakes

  • Calling getCurrentScope() outside any scope and expecting a default — returns null. Handle the no-scope case explicitly

  • Using getCurrentScope() as a substitute for effectScope() — it returns the AMBIENT scope, not a fresh one

See also: effectScope · setCurrentScope


setCurrentScope function

(scope: EffectScope | null) => void

Low-level escape hatch — directly set the ambient EffectScope. Use only when implementing scope-aware framework primitives (e.g. mountReactive, custom render boundaries). Most code should use scope.runInScope(fn) which sets and restores via try/finally. Pairing setCurrentScope(s) with a manual setCurrentScope(prev) is error-prone — runInScope is the safe form.

Example

// Inside a custom render boundary that needs to swap scopes mid-flow:
const prev = getCurrentScope()
setCurrentScope(myScope)
try {
  doWork()
} finally {
  setCurrentScope(prev)
}
// Or — preferred:
myScope.runInScope(() => doWork())

Common mistakes

  • Forgetting to restore the previous scope — leaks effects to the wrong owner forever

  • Using setCurrentScope instead of runInScope in user code — the safe API is runInScope

See also: effectScope · getCurrentScope


onSignalUpdate function

(listener: (event: { signal, name, prev, next, stack, timestamp }) => void) => () => void

Register a global trace listener that fires on every signal write. Returns a disposer. Dev/debug only — every signal write incurs the listener call. Use for time-travel debugging, recording reactive transcripts in tests, or building devtools panels. Multiple listeners are supported (each gets every event).

Example

import { onSignalUpdate, signal } from '@pyreon/reactivity'

const dispose = onSignalUpdate(e => {
  console.log(`${e.name ?? '(anonymous)'}: ${e.prev} → ${e.next}`)
})
const count = signal(0, { name: 'count' })
count.set(5)   // logs: count: 0 → 5
dispose()      // remove listener

Common mistakes

  • Leaving onSignalUpdate registered in production — fires on EVERY signal write, even hot-path internal ones. Always dispose when done

  • Throwing inside the listener — corrupts the signal's notification flow (the listener fires after _v is updated but before subscribers are notified). Wrap your handler in try/catch

  • Expecting the event to capture writes that occur via batch flushes — the event fires per set() call, regardless of batch state

See also: inspectSignal · why


inspectSignal function

<T>(sig: Signal<T>) => SignalDebugInfo<T>

Inspect a signal — pretty-prints its current value, name, and subscriber count to the console (in a console.group) and returns the debug info object. Useful for one-shot inspection while debugging; for continuous tracing use onSignalUpdate.

Example

const count = signal(0, { name: 'count' })
inspectSignal(count)
// Console group:
//   🔍 Signal "count"
//     value: 0
//     subscribers: 2

Common mistakes

  • Calling inspectSignal in production — produces console noise. Gate calls behind if (import.meta.env.DEV) or __DEV__

See also: onSignalUpdate · why


why function

() => void

Toggle a global "why-did-it-update?" tracer that logs every signal write between consecutive calls. Calling once arms the tracer; calling again disarms it and dumps the captured transcript. Dev/debug only. Useful for hunting "why did this effect just re-run?" — wrap a suspicious operation, call why() before and after, see exactly which signals changed.

Example

why()         // arm tracer
clickButton()  // any signal writes here are captured
why()         // disarm + dump transcript:
//   [pyreon:why] "filter": "all" → "active" (12 subscribers)
//   [pyreon:why] "scrollY": 0 → 240 (1 subscriber)

Common mistakes

  • Calling why() once and forgetting to call it again — keeps tracing forever, leaks the listener, prints nothing until disarmed

  • Using why() in production — pure dev tool

See also: onSignalUpdate · inspectSignal


getReactiveTrace function

() => Array<{ name: string | undefined; prev: string; next: string; timestamp: number }>

Returns the last ~50 signal writes (chronological, oldest → newest) from a bounded dev-only ring buffer — the causal SEQUENCE of reactive state changes, not a point-in-time snapshot. @pyreon/core attaches this to ErrorContext.reactiveTrace automatically so error reports carry "what changed in the run-up to the crash". Entries hold bounded string previews of values (never raw refs — no memory pinning, always serializable). Dev-only: the recorder feeding the buffer is behind the production dead-code gate and tree-shakes out, so this returns [] in prod builds. Distinct from onSignalUpdate — that is opt-in and captures stacks; this is always-on, deliberately cheap, and exists to enrich error reports. clearReactiveTrace() resets it (test isolation).

Example

import { getReactiveTrace, clearReactiveTrace, signal } from '@pyreon/reactivity'

const status = signal('idle', { name: 'status' })
status.set('submitting')
getReactiveTrace()
// [{ name: 'status', prev: '"idle"', next: '"submitting"', timestamp: 1234.5 }]
clearReactiveTrace()  // → []

Common mistakes

  • Expecting it to return signal VALUES — it returns string PREVIEWS (truncated, safely stringified). For live values inspect the signal directly

  • Relying on it in production — returns [] (the recorder is dev-gated and tree-shaken). Use it for dev tooling / error-report enrichment, not runtime logic

  • Treating it as a snapshot of all signals — it is a bounded ring of recent WRITES; signals never written (or written before the ~50-entry window) are absent

See also: onSignalUpdate · inspectSignal


setErrorHandler function

(fn: (err: unknown) => void) => void

Register a global handler for unhandled errors thrown inside effect() / computed() / renderEffect(). Without a handler, errors are logged to console.error and the effect re-throws (potentially crashing the surrounding frame). With one, the framework calls your handler with the thrown value and continues. Use for telemetry / error-boundary integration. One handler only — calling twice replaces the first.

Example

setErrorHandler(err => {
  reportToSentry(err)
  toast.error('Something went wrong')
})

effect(() => {
  if (count() > 100) throw new Error('count too high')
})
count.set(101)  // logs/reports via handler instead of crashing

Common mistakes

  • Calling setErrorHandler multiple times and expecting all to fire — the second call REPLACES the first. Compose multiple handlers manually if you need a chain

  • Throwing inside the handler — the framework will swallow this too, but you lose visibility. Make handlers no-throw (try/catch internally if needed)

  • Expecting the handler to receive errors from signal.set() writes — only effect-runtime errors are routed. Synchronous errors at write time bubble up normally

See also: effect · renderEffect


activateReactiveDevtools function

activateReactiveDevtools(): void  ·  deactivateReactiveDevtools(): void  ·  isReactiveDevtoolsActive(): boolean

Opt-in lifecycle for the reactive-devtools bridge — the live signal/computed/effect graph the @pyreon/devtools Signals/Graph/Effects/Profiler tabs consume (surfaced on the browser hook as window.__PYREON_DEVTOOLS__.reactive). Zero cost until activated: every per-primitive instrumentation point early-returns on the inactive flag and sits inside the production dead-code gate, so it tree-shakes out of prod builds entirely (locked by a minified-bundle test) and, in dev, costs one predicted-false branch until a devtools client calls activate() — the same risk profile as the adjacent reactive-trace / perf-harness calls. deactivate() drops all retained registry + fire-buffer state (a closed panel leaves zero residue). Leak-free by construction: nodes are held via WeakRef + FinalizationRegistry, never pinned.

Example

import { activateReactiveDevtools, getReactiveGraph } from '@pyreon/reactivity'

// Only AFTER activation are subsequently-created signals tracked.
activateReactiveDevtools()
const price = signal(10, { name: '$price' })
const total = computed(() => price() * 2)
effect(() => { total() })
getReactiveGraph().nodes // → [$price (signal), derived, effect]
deactivateReactiveDevtools() // → registry cleared

Common mistakes

  • Expecting nodes created BEFORE activate() to appear — registration is gated on the active flag (mirrors a devtools panel attaching). Activate first, then build/observe the graph

  • Calling it in production for app logic — the whole bridge is dev-gated and tree-shaken; getReactiveGraph() returns an empty graph in prod builds

  • Assuming it tracks compiler-emitted DOM bindings — only user signal() / computed() / effect() are registered; renderEffect / _bind plumbing is intentionally excluded (it would flood the graph and tax the hottest path)

See also: getReactiveGraph · onSignalUpdate · getReactiveTrace


getReactiveGraph function

getReactiveGraph(): { nodes: ReactiveNode[]; edges: { from: number; to: number }[] }  ·  getReactiveFires(): { id: number; ts: number }[]

Fresh snapshot of the live reactive graph + a bounded recent-fire timeline, for the reactive-devtools tabs. getReactiveGraph() returns every tracked node ({ id, kind: "signal"|"derived"|"effect", name, value, subscribers, fires, lastFire }) plus dependency edges recomputed on demand from the real subscriber _s Sets (source → subscriber: signal→derived, derived→effect) — always consistent with the framework’s actual subscription state, no incremental drift. getReactiveFires() returns a fixed-size ring buffer of recent fires ({ id, ts }, oldest → newest) powering the Effects/Profiler tabs. Both require activateReactiveDevtools() first and return empty otherwise. Names come from signal(v, { name }) / the vite-plugin dev auto-naming; anonymous computeds/effects get a synthetic derived#id / effect#id.

Example

activateReactiveDevtools()
const a = signal(1, { name: '$a' })
const b = computed(() => a() + 1)
effect(() => { b() })
a.set(2)
getReactiveGraph()
// nodes: [{ name:'$a', kind:'signal', value:'2', … }, { kind:'derived', … }, { kind:'effect', … }]
// edges: [{ from:$a, to:derived }, { from:derived, to:effect }]
getReactiveFires() // → [{ id, ts }, …]  (bounded, chronological)

Common mistakes

  • Holding the returned arrays expecting them to update — they are point-in-time snapshots; call again (the devtools panel polls)

  • Reading node.value for non-string state as the real value — it is a bounded, safely-stringified PREVIEW (never a raw ref — no pinning). Inspect the signal directly for the live value

  • Expecting fires for every write in a long-running app — getReactiveFires() is a fixed-size ring; older entries roll off

See also: activateReactiveDevtools · getReactiveTrace · onSignalUpdate


describeReactiveGraph function

describeReactiveGraph(graph?: ReactiveGraph): GraphDescription  ·  formatGraphDescription(desc): string

Auto-generated BEHAVIORAL description of the reactive graph — what a change to each signal actually DOES, in English, plus health insights only the graph shape can surface. No framework generates behavioral (not API) docs from the reactive graph; Pyreon can because it holds the precise graph. Returns { summary, nodes, insights }: each nodes[] entry has an English behavior one-liner (a signal describes its downstream fan-out — 'changing it re-derives N values and runs M effects'; a computed/effect describes what it reacts to — 'recomputes when qty, price change'). insights flags behavioral smells: orphan-signal (nothing depends on it — dead reactivity), high-fanout (a change re-runs many effects — a hot signal), deep-chain (end of a long dependency chain). Pure over getReactiveGraph(); dev/test only. Pairs with getUpdateCause — this describes the whole graph, that explains one update.

Example

activateReactiveDevtools()
const qty = signal(2, { name: 'qty' })
const shippingFlat = signal(4, { name: 'shippingFlat' }) // never read → orphan
const total = computed(() => qty() * 9.99)
effect(() => { void total() })
console.log(formatGraphDescription(describeReactiveGraph()))
// Signals:
//   qty            changing it re-derives 1 value and runs 1 effect
//   shippingFlat   nothing reacts to it (no dependents)
// Insights: orphan-signal  nothing depends on `shippingFlat`

Common mistakes

  • Running it in production — the reactive registry is tree-shaken when NODE_ENV === "production", so the graph (and description) is empty.

  • Treating an orphan-signal insight as always a bug — a signal read only outside a tracking scope (an event handler) legitimately has no graph dependents; it flags the SHAPE, you confirm intent.

  • Expecting it to describe a component from SOURCE without running — it reads the LIVE graph (getReactiveGraph()), so the reactive nodes must have been created + subscribed first.

See also: getReactiveGraph · getUpdateCause · activateReactiveDevtools


getUpdateCause function

getUpdateCause(nodeId: number): UpdateCause | null  ·  formatUpdateCause(cause: UpdateCause): string

Answers "why did this node just update?" at the SOURCE LINE, along the exact causal chain — the thing React DevTools' whole-component "why did this render?" can't. getUpdateCause reconstructs the chain that led to a node's most recent fire by walking the dependency graph from the target through the deps that fired in the SAME synchronous cascade (clustered within ~one animation frame). Returns { target, chain, rootReached } where chain is ROOT-FIRST (chain[0] is the originating signal write) and each CauseLink is { id, kind, name, loc, ts }. formatUpdateCause renders it as a source-anchored trace. Purely READ-time over getReactiveGraph() + getReactiveFires() — zero hot-path cost. The graph (not the timeline) is the causal structure: a lazy computed recomputes DURING its subscriber's read, so temporal order ≠ causal order. Also on window.__PYREON_DEVTOOLS__.reactive. Requires activateReactiveDevtools().

Example

activateReactiveDevtools()
const qty = signal(2, { name: 'qty' })
const total = computed(() => qty() * 9.99)
effect(() => { void total() })
qty.set(5)
const effectId = getReactiveGraph().nodes.find(n => n.kind === 'effect').id
console.log(formatUpdateCause(getUpdateCause(effectId)))
// Why did effect#4 update?
//   qty (signal) changed  Cart.tsx:2:13
//   → total (derived) recomputed
//   → effect#4 (effect) ran   ← explained

Common mistakes

  • Trusting the chain across UNRELATED rapid interactions — reconstruction is exact for one synchronous cascade; interactions within the ~16ms cluster window can blur. rootReached: false means older fires aged out of the ring buffer.

  • Calling it in production — the reactive registry is tree-shaken when NODE_ENV === "production", so getUpdateCause returns null (nothing tracked).

  • Expecting timeline order to be causal order — it is NOT (a lazy computed recomputes during its subscriber's read). getUpdateCause uses the dependency graph as the causal structure on purpose.

See also: getReactiveGraph · activateReactiveDevtools · getReactiveTrace


wrapSignal function

<T>(base: Signal<T>, options: WrapSignalOptions<T>) => Signal<T>

Create a signal facade over a base signal with custom write behavior. The canonical way to build a signal whose write runs a side effect (persistence, validation, patch emission). Reads and the internal _v field are delegated to base, so the facade satisfies the full signal contract the compiler's fast paths depend on — a facade that exposes .direct() but forgets _v (or vice-versa) silently binds to undefined and renders empty. wrapSignal forwards both by construction, making hand-rolled facades structurally impossible to get wrong.

Example

const base = signal(0)
const wrapped = wrapSignal(base, {
  set: (v) => {
    base.set(v)
    localStorage.setItem('key', JSON.stringify(v))
  },
})
wrapped.set(5)  // writes through custom logic
console.log(wrapped())  // 5 (reads from base)

Common mistakes

  • Forgetting to call base.set(value) inside the custom set handler — the signal's internal _v never updates, so reads stall at the old value and compiled fast paths see stale data.

  • Hand-rolling a facade that exposes .direct() but not the _v property — the compiler's _bindText fast path reads source._v directly to skip the function call; without it, text bindings render empty even though () works.

  • Capturing the base signal reference in closure and forgetting to return the facade itself — callers that track per-instance subscription identity (e.g. .remove() on shared state) break because every call returns the same identity.

  • Using wrapSignal to add tracking to a signal that's written from multiple code paths without coordinating in the custom set handler — the side effect runs on EVERY write, including batch-deferred ones; if other code writes directly to base it bypasses the facade.

  • Expecting the custom update option to be optional AND have a default — if not provided, it defaults to set(fn(base.peek())), which may not match your intent if set has expensive side effects (it will run for every update, even pure derive ops).

See also: signal · effect · computed


WrapSignalOptions type

interface WrapSignalOptions<T> { set: (value: T) => void; update?: (fn: (current: T) => T) => void }

Configuration object for wrapSignal(). The set handler runs in place of the base signal's .set(); call through to base.set(value) when the value should actually update. The optional update handler defaults to set(fn(peek())) — override it if you need custom batch or coalescing behavior that .set() alone doesn't express.

Example

const store = createStore({ count: 0 })
const countSig = signal(store.count)
const wrapped = wrapSignal(countSig, {
  set: (v) => {
    countSig.set(v)
    store.count = v  // dual-write to store
  },
  update: (fn) => {
    const next = fn(countSig.peek())
    wrapped.set(next)  // coordinated update
  },
})

Common mistakes

  • set handler not calling through to base.set() — the wrapped signal's value never updates internally, all reads stall.

  • Providing update that doesn't eventually call set — reads won't reflect the update unless set is involved in the chain.

  • Expecting the default update to coalesce rapid-fire calls — it calls set() directly, so if set has side effects (persist, emit), they fire on every update call without coalescing.

  • Using the same options object for multiple wrapSignal() calls on different bases — the set and update closures capture the wrong base reference if reused.

See also: wrapSignal · Signal


startReactiveCoverage function

() => void

Begin a Reactive Coverage session (from the @pyreon/reactivity/coverage subpath). Resets the dev registry to a clean baseline, pins every node created from here on (so an unmounting component isn't GC-pruned out of the denominator), and enables graph reads. Create + exercise the reactive code you want to measure AFTER calling this. Pairs with takeReactiveCoverage() + stopReactiveCoverage(). Dev/test only — the registry is tree-shaken in production.

Example

import { startReactiveCoverage, takeReactiveCoverage, stopReactiveCoverage, formatReactiveCoverage } from '@pyreon/reactivity/coverage'

startReactiveCoverage()
// … mount a component / run a test scenario …
const report = takeReactiveCoverage()
stopReactiveCoverage()
console.log(formatReactiveCoverage(report))

Common mistakes

  • Creating the signals/effects you want to measure BEFORE startReactiveCoverage() — the baseline reset wipes pre-session nodes; create them after.

  • Expecting coverage in a production build — the reactive registry is tree-shaken when NODE_ENV === "production", so the report is a vacuous 100%.

See also: takeReactiveCoverage · stopReactiveCoverage · computeReactiveCoverage


takeReactiveCoverage function

() => ReactiveCoverageReport

Snapshot the current Reactive Coverage session into a ReactiveCoverageReport — { total, covered, uncovered, percent, byKind, entries, uncoveredEntries }, where each entry is { id, kind, name, fires, subscribers, covered, reason, loc }. A node is covered when its reactive behaviour actually fired: signals when they changed (fires ≥ 1), effects/derived when they RE-ran past their mount run (fires ≥ 2). The ran-once reason flags the interesting bucket — a mounted effect/computed whose reactive re-run was never triggered. Call any number of times while the session is active.

Example

const report = takeReactiveCoverage()
console.log(report.percent, 'covered') // e.g. 42.9
for (const e of report.uncoveredEntries) console.log(e.reason, e.name, e.loc)

See also: startReactiveCoverage · formatReactiveCoverage · computeReactiveCoverage


formatReactiveCoverage function

(report: ReactiveCoverageReport, opts?: { showCovered?: boolean; limit?: number }) => string

Render a ReactiveCoverageReport as a dependency-free, human-readable text block: a headline (Reactive Coverage — 42.9% (3 of 7 …)), a per-kind line (signals 1/3 derived 1/2 effects 1/2), and the uncovered list with each node's reason + source location. The machine-readable form is the ReactiveCoverageReport itself; computeReactiveCoverage(nodes) is the pure function that builds it from getReactiveGraph().nodes.

Example

console.log(formatReactiveCoverage(report))
// Reactive Coverage — 42.9% (3 of 7 reactive nodes exercised)
//   signals 1/3   derived 1/2   effects 1/2
console.log(formatReactiveCoverage(report, { showCovered: true, limit: 20 }))

See also: takeReactiveCoverage · computeReactiveCoverage


SignalValue type

type SignalValue<S> = S extends () => infer T ? T : never

Unwrap the VALUE type of a Signal<T>, Computed<T>, ReadonlySignal<T>, or any zero-arg accessor — SignalValue<typeof count> is number for const count = signal(0). Derive, don't annotate twice: when a function's parameter should be "whatever that signal holds", derive it from the signal instead of repeating the value type. Type-only export — zero runtime bytes.

Example

const user = signal({ id: 1, name: 'Ada' })
type User = SignalValue<typeof user> // { id: number; name: string }

function save(next: SignalValue<typeof user>) { user.set(next) }

Common mistakes

  • SignalValue<number> — resolves to never; the input must be the SIGNAL type (typeof mySignal), not the value type itself

  • Passing the signal where the unwrapped value is expected — save(user) fails typecheck; read it first: save(user())

  • Using it on a function that REQUIRES arguments — only zero-arg callables unwrap; (x: number) => string resolves to never by design

See also: signal · ComputedValue · MaybeAccessor


ComputedValue type

type ComputedValue<C> = C extends () => infer T ? T : never

Unwrap the value type of a Computed<T> — intent-revealing alias of SignalValue (every Pyreon reactive read is a zero-arg callable, so one conditional covers both). Type-only, zero runtime bytes.

Example

const total = computed(() => price() * qty())
type Total = ComputedValue<typeof total> // number

Common mistakes

  • ComputedValue<ReturnType<typeof computed>> gymnastics — pass typeof total directly

  • Expecting it to unwrap a plain derived VALUE — a non-callable resolves to never

See also: computed · SignalValue


MaybeAccessor type

type MaybeAccessor<T> = T | (() => T)

The standard "static value OR reactive accessor" parameter shape used across Pyreon APIs (<Show when>, hook options). NOT auto-called — code accepting a MaybeAccessor<T> must resolve it itself (typeof v === "function" ? v() : v) and should do that read inside a reactive scope so the accessor form tracks. Pair with AccessorReturn to derive the resolved type. Type-only, zero runtime bytes.

Example

function useTitle(title: MaybeAccessor<string>) {
  const read = () => (typeof title === 'function' ? title() : title)
  effect(() => { document.title = read() }) // accessor form stays reactive
}
useTitle('Static')
useTitle(() => pageTitle())

Common mistakes

  • MaybeAccessor is NOT auto-called — the ACCEPTING code must resolve it with a typeof v === "function" check; passing an accessor to code that only reads the value renders the function source

  • Resolving it ONCE at setup (const v = typeof title === "function" ? title() : title) — captures the accessor's value statically; resolve INSIDE the effect/JSX accessor to keep tracking

  • Using it for a FUNCTION-valued parameter (MaybeAccessor<() => void>) — the two union arms are runtime-ambiguous; use a dedicated options field instead

  • Discriminating with a framework brand check when plain typeof v === "function" suffices — a Signal IS a () => T, the accessor arm covers it

See also: AccessorReturn · SignalValue · signal


AccessorReturn type

type AccessorReturn<A> = A extends () => infer T ? T : A

Resolve a MaybeAccessor (or any accessor) to its VALUE type — unwraps the () => T arm and passes plain values through unchanged (AccessorReturn<MaybeAccessor<T>> round-trips to T). Type-only, zero runtime bytes.

Example

type A = AccessorReturn<() => number>           // number
type B = AccessorReturn<string>                  // string
type C = AccessorReturn<MaybeAccessor<boolean>>  // boolean

Common mistakes

  • Confusing it with SignalValue — AccessorReturn<number> is number (pass-through) while SignalValue<number> is never (strict: input must be callable)

See also: MaybeAccessor · SignalValue


registerSingleton function

(pkg: string, version: string, location: string) => void

Fail-loud detection of duplicate framework instances. Every @pyreon/* package with module-level state calls this once at the TOP of its own src/index.ts — registerSingleton(name, version, import.meta.url), with name/version pulled from that package's own package.json. Bundlers can produce TWO instances of the same package (Vite bare-vs-entry resolver divergence, sub-dependency version mismatches, workspace + npm-published mixes), and each instance has its OWN module-level state, so a producer on instance A and a consumer reading instance B silently break every framework invariant. The FIRST registration for a package name records a marker; a SECOND with a DIFFERENT (query-string-normalized, so Vite HMR re-evals are allowed) module location triggers detection: 'throw' (default — the loud failure), 'warn' (log via console.error and continue), or 'silent' (the escape hatch for browser extensions, micro-frontends, and nested SSR harnesses that legitimately dual-load). Controlled by the PYREON_SINGLE_INSTANCE env var.

Example

// Top of any package's own src/index.ts:
import { name as __pkgName, version as __pkgVersion } from '../package.json' with { type: 'json' }
import { registerSingleton } from '@pyreon/reactivity'

registerSingleton(__pkgName, __pkgVersion, import.meta.url)
// ...rest of the package's exports

Common mistakes

  • Hardcoding the version string literal instead of importing it from package.json — a release bump then leaves the sentinel reporting a stale version forever, defeating the diagnostic value of a version-skew report

  • Registering with a name/location that does not match how the package is actually imported — the location is what discriminates "same module, re-evaluated by HMR" from "genuinely two instances"; get it from import.meta.url at the top of the real entry module

  • Wrapping the call in a try/catch — it deliberately has none; swallowing the throw here hides the exact bug class it exists to surface

See also: withSilent


defineCrossModuleState function

<T extends object>(key: string, init: () => T) => T

A globalThis-keyed singleton, for state that must be shared even across a genuine dual-instance situation (as opposed to registerSingleton, which DETECTS and warns about dual instances). key is looked up via Symbol.for(key) (a GLOBAL symbol registry entry — the same string always resolves to the same symbol, even across separately-loaded module instances). The FIRST call with a given key runs init() and stores the result on globalThis; every subsequent call with the same key — from ANY module instance — returns the IDENTICAL object reference. Re-exported from @pyreon/core for convenience.

Example

import { defineCrossModuleState } from '@pyreon/reactivity'

const registry = defineCrossModuleState('my-lib:widget-registry', () => new Map<string, unknown>())
// Every module instance of "my-lib" that calls this with the SAME key shares the SAME Map.

Common mistakes

  • Returning a primitive from init() — the constraint is T extends object; primitives cannot be mutated in place, so cross-instance updates would not propagate. Return a mutable object/Map/Set

  • Using a non-globally-unique key — Symbol.for is a GLOBAL registry, so a generic key like 'state' can collide with an unrelated package doing the same thing; namespace your key ('my-lib:feature-name')

  • Reaching for this as a substitute for registerSingleton — that function DETECTS and warns/throws on accidental dual-instancing (the bug you usually want surfaced); this one SILENTLY shares state across instances, which is only correct when dual-instancing is expected and harmless for this particular piece of state

See also: registerSingleton


getContextOwner function

() => EffectScope | null

Read the currently active CONTEXT OWNER — the EffectScope that @pyreon/core's provide()/useContext() resolve against. Distinct from getCurrentScope(): the context owner tracks the COMPONENT TREE (set by the renderer at mount, mirroring parent→child structure), while getCurrentScope() tracks EFFECT NESTING. A component's EffectScope doubles as its context owner — provide() writes onto scope._contexts, and useContext() walks scope._parent up the owner chain. Framework primitives that defer mounting past the synchronous render frame (an island, a lazy boundary, a scheduled re-mount) must capture this at setup time and restore it with runWithContextOwner when they finally mount — otherwise useContext() inside the deferred content resolves against whatever owner happens to be active later, not the real ancestor chain.

Example

// A primitive that defers mounting (simplified island shape):
const owner = getContextOwner()   // capture while the real ancestor chain is active
scheduleLater(() => {
  runWithContextOwner(owner, () => hydrateRoot(el, Component))
})

Common mistakes

  • Assuming this returns the same thing as getCurrentScope() — it does not; one tracks context/component-tree ownership, the other tracks effect nesting. They usually coincide during synchronous mount and DIVERGE after a deferred/async boundary

  • Forgetting to capture this BEFORE an async gap (an await import(), a setTimeout) in code that will call useContext() later — by the time the deferred code runs, the owner active at capture time is gone unless you threaded it through runWithContextOwner

See also: runWithContextOwner · setContextOwner · getCurrentScope


runWithContextOwner function

<T>(owner: EffectScope | null, fn: () => T) => T

Run fn with owner as the active context owner, then restore whatever was active before — the safe, try/finally-guarded way to temporarily swap the context owner (mirrors scope.runInScope for effect scopes). This is how a deferred boundary (an island's late hydration, <Show>/<For> mounting children) re-establishes the REAL ancestor chain for a mount that happens after the synchronous render frame — capture the owner with getContextOwner() while it is genuinely active, then wrap the later mount call in runWithContextOwner(capturedOwner, () => …) so useContext() inside it resolves against the right provider.

Example

const owner = getContextOwner()
onIdle(() => {
  runWithContextOwner(owner, () => {
    // useContext() calls inside this mount now resolve against the
    // REAL ancestor chain captured above, not whatever owner (if any)
    // happens to be active when the idle callback actually fires.
    mountDeferredContent()
  })
})

Common mistakes

  • Using setContextOwner + a manual restore instead of this — runWithContextOwner restores via try/finally even if fn throws; a hand-written set/restore pair leaks the owner on an exception

  • Capturing the owner too LATE — getContextOwner() must run while the real ancestor chain is genuinely active (synchronously, at setup), not inside the deferred callback itself

See also: getContextOwner · setContextOwner


setContextOwner function

(owner: EffectScope | null) => EffectScope | null

Low-level escape hatch — directly set the active context owner, returning the PREVIOUS one (for a manual restore). Prefer runWithContextOwner(owner, fn), which does the same swap with a try/finally-guaranteed restore; reach for this only when implementing a framework primitive that cannot express its owner-swap as a single synchronous fn call (mirrors the setCurrentScope/runInScope relationship for effect scopes one level up).

Example

const prev = setContextOwner(myOwner)
try {
  doWork()
} finally {
  setContextOwner(prev)
}
// Prefer, when the swap fits a single call:
runWithContextOwner(myOwner, () => doWork())

Common mistakes

  • Forgetting to restore the previous owner (or restoring unconditionally instead of in a finally) — leaves subsequent useContext() calls resolving against the wrong ancestor chain until something else happens to reset it

  • Using this in ordinary application code instead of runWithContextOwner — this is a framework-primitive-authoring escape hatch, not a general-purpose API

See also: runWithContextOwner · getContextOwner


Package-level notes

Signals are callable functions: Pyreon signals are NOT .value getters (Vue ref) or [state, setState] tuples (React useState). The signal IS the function: count() reads, count.set(v) writes, count.update(fn) derives. This is the #1 confusion for developers coming from other frameworks.

No dependency arrays: effect() and computed() auto-track dependencies on each execution — no [dep1, dep2] array needed. Every signal read inside the body is a tracked dependency. This means conditional reads (if (cond()) { return x() }) only track x when cond() is true.

Standalone: @pyreon/reactivity has zero dependencies. Use it in Node/Bun scripts, edge workers, or any JavaScript environment without pulling in the rest of the framework. @pyreon/core and @pyreon/runtime-dom build on it but are not required.

Complete API — API Reference