@pyreon/reactivity — API Reference
Generated from
reactivity'ssrc/manifest.ts— the same source that powersllms.txtand MCPget_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
equalseffect() / 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
| Symbol | Kind | Summary |
|---|---|---|
signal | function | Create a reactive signal. |
isServer | constant | Canonical runtime environment flag — true when there is no DOM (typeof document === 'undefined'), i.e. |
isClient | constant | Inverse of isServer — true on a browser main thread where a DOM is available (typeof document !== 'undefined'). |
computed | function | Create a derived value that caches its result and recomputes lazily. |
effect | function | Run a side effect that auto-tracks signal dependencies and re-runs when they change. |
renderEffect | function | DOM-specific effect with a lighter dependency tracking path — uses a local array for deps instead of the full `EffectSco |
batch | function | Group multiple signal writes so subscribers fire only once — after the batch completes. |
nextTick | function | Returns a promise that resolves after the next microtask. |
onCleanup | function | Register a cleanup with the CURRENT OWNER. |
watch | function | Explicit reactive watcher — tracks source and fires callback when it changes. |
createSelector | function | Create an O(1) equality selector — returns a reactive predicate that fires only when the previously-selected and newly-s |
cell | function | Lightweight reactive primitive — class-based alternative to signal(). |
createStore | function | Create a deeply reactive proxy-based object. |
createResource | function | Async data primitive. |
reconcile | function | Surgically diff a new value into an existing createStore proxy. |
isStore | function | Type guard — returns true if the value is a createStore proxy (recognized via an internal symbol marker). |
shallowReactive | function | Create a SHALLOW reactive store — only top-level mutations trigger updates. |
markRaw | function | Mark an object as RAW — createStore and shallowReactive will return it unwrapped. |
untrack | function | Execute a function reading signals WITHOUT subscribing to them. |
effectScope | function | Create an EffectScope — a container that auto-tracks effects/computeds created inside scope.runInScope(fn) and dispo |
onScopeDispose | function | Register a callback to run when the current EffectScope stops. |
getCurrentScope | function | Returns the currently active EffectScope (the one whose runInScope(fn) is on the stack), or null if no scope is ac |
setCurrentScope | function | Low-level escape hatch — directly set the ambient EffectScope. |
onSignalUpdate | function | Register a global trace listener that fires on every signal write. |
inspectSignal | function | Inspect a signal — pretty-prints its current value, name, and subscriber count to the console (in a console.group) and |
why | function | Toggle a global "why-did-it-update?" tracer that logs every signal write between consecutive calls. |
getReactiveTrace | function | Returns the last ~50 signal writes (chronological, oldest → newest) from a bounded dev-only ring buffer — the causal SEQ |
setErrorHandler | function | Register a global handler for unhandled errors thrown inside effect() / computed() / renderEffect(). |
activateReactiveDevtools | function | Opt-in lifecycle for the reactive-devtools bridge — the live signal/computed/effect graph the @pyreon/devtools Signals |
getReactiveGraph | function | Fresh snapshot of the live reactive graph + a bounded recent-fire timeline, for the reactive-devtools tabs. |
describeReactiveGraph | function | Auto-generated BEHAVIORAL description of the reactive graph — what a change to each signal actually DOES, in English, pl |
getUpdateCause | function | Answers "why did this node just update?" at the SOURCE LINE, along the exact causal chain — the thing React DevTools' wh |
wrapSignal | function | Create a signal facade over a base signal with custom write behavior. |
WrapSignalOptions | type | Configuration object for wrapSignal(). |
startReactiveCoverage | function | Begin a Reactive Coverage session (from the @pyreon/reactivity/coverage subpath). |
takeReactiveCoverage | function | Snapshot the current Reactive Coverage session into a ReactiveCoverageReport — `{ total, covered, uncovered, percent, |
formatReactiveCoverage | function | Render a ReactiveCoverageReport as a dependency-free, human-readable text block: a headline (`Reactive Coverage — 42.9 |
SignalValue | type | Unwrap the VALUE type of a Signal<T>, Computed<T>, ReadonlySignal<T>, or any zero-arg accessor — `SignalValue<type |
ComputedValue | type | Unwrap the value type of a Computed<T> — intent-revealing alias of SignalValue (every Pyreon reactive read is a zero |
MaybeAccessor | type | The standard "static value OR reactive accessor" parameter shape used across Pyreon APIs (<Show when>, hook options). |
AccessorReturn | type | Resolve a MaybeAccessor (or any accessor) to its VALUE type — unwraps the () => T arm and passes plain values throug |
registerSingleton | function | Fail-loud detection of duplicate framework instances. |
defineCrossModuleState | function | A globalThis-keyed singleton, for state that must be shared even across a genuine dual-instance situation (as opposed to |
getContextOwner | function | Read the currently active CONTEXT OWNER — the EffectScope that @pyreon/core's provide()/useContext() resolve aga |
runWithContextOwner | function | Run fn with owner as the active context owner, then restore whatever was active before — the safe, try/finally-guard |
setContextOwner | function | Low-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
| Parameter | Type | Description |
|---|---|---|
initialValue | T | The 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-runCommon mistakes
count.value— does not exist. Usecount()to readcount = 5— reassigning the variable replaces the signal, does not write to it. Usecount.set(5)signal(5)called with an argument after creation — reads and ignores the argument (dev mode warns). Use.set(5)to writeconst [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()insideeffect()/computed()— bypasses tracking, creates stale reads. Only use.peek()for loop-prevention guardsMutating a held object in place then
set(sameReference)— a no-op (Object.is gate), subscribers never re-run. Prefer an immutableset(newObject); if you deliberately own a mutable value, mutate then.trigger()
See also: computed · effect · batch
isServer constant
const isServer: booleanCanonical 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 windowmisreports Deno / polyfilled Node; importisServer/isClientReaching for
isClientto gate DOM access inside a component — preferonMount/effect, which never run during SSRPutting heavy server-only code behind
isServerin a client-imported module — it still ships to the client bundle; use a/serversubpath export instead
See also: isClient
isClient constant
const isClient: booleanInverse 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 : trueCommon mistakes
Rolling your own
const isBrowser = typeof window !== 'undefined'instead — importisClientUsing
isClientto defer DOM work that belongs inonMount/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
| Parameter | Type | Description |
|---|---|---|
fn | () => T | Derivation — 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() // 10Common mistakes
computed(() => count)— must CALL the signal:computed(() => count())Using
computed()for side effects — useeffect()instead; computed is for pure derivationExpecting
computed()to re-run when a.peek()-read signal changes —.peek()bypasses trackingExpecting eager evaluation — the default computed is LAZY: a dependency change only marks it dirty; the derivation runs on the next read. Pass
options.equalsfor the eager variant that re-evaluates on notification and gates downstream updatesAssuming
computed()de-duplicates identical results — it does NOT. A signal write gates onObject.is, soset(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.isif that is what you meant) to gate on valueReasoning 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) => () => voidRun 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
| Parameter | Type | Description |
|---|---|---|
fn | () => (() => void) | void | Effect 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()insideeffect()— usecomputed()for derived values insteadCreating signals inside an effect — they re-create on every run; create once outside
Passing an
asyncfunction — only signal reads BEFORE the firstawaitare tracked (dev mode warns at registration); read every tracked signal before awaiting, or usewatch(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) => () => voidDOM-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
| Parameter | Type | Description |
|---|---|---|
fn | () => void | DOM-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()insiderenderEffect()— not supported; onlyeffect()collects cleanups. Useeffect()if you need cleanup callbacksAssuming
renderEffect()supports the fulleffect()surface — it auto-registers its DISPOSER with the surroundingEffectScope(tears down on unmount likeeffect()), but has noonCleanupcollection, no error-handler routing, and returns a bare dispose function, not anEffectobjectReaching 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) => voidGroup 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
| Parameter | Type | Description |
|---|---|---|
fn | () => void | Body 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 twiceCommon 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 returnsForgetting
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 abatch()callback — pointless; the batch flushes when the callback returns, not when the microtask drains. Move the await outsidebatch()Using
nextTick()to defer work — it doesn't schedule anything; it just resolves on the next microtask. UsesetTimeout/requestAnimationFramefor actual deferral
See also: batch
onCleanup function
(fn: () => void) => voidRegister 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
| Parameter | Type | Description |
|---|---|---|
fn | () => void | Cleanup 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 —renderEffecthas no collector; the cleanup goes to the enclosing owner (usually the component) and runs on unmountCalling
onCleanupin plain module code with no owner — there is nothing to attach it to, so it never runsAssuming a component-body
onCleanupruns when a parent<For>/<Show>re-renders — it belongs to the component and runs only when that component unmountsRegistering the same teardown with both
onCleanupand 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) => () => voidExplicit 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
| Parameter | Type | Description |
|---|---|---|
source | () => T | Tracked source — its return value is watched for changes. |
callback | (next: T, prev: T) => void | Runs on change with the new and previous source values. |
options? | WatchOptions | Optional 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
sourcefunction — it runs at setup time (not just in mounted context), sono-window-in-ssrfires onwindow.XthereExpecting signals read inside the
callbackto be tracked — only thesourcefunction establishes tracking; the callback is untrackedForgetting to return a cleanup function from the callback —
watchhonors 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) => booleanCreate 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
| Parameter | Type | Description |
|---|---|---|
source | () => T | Tracked 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() === valuein lists — every row subscribes to source; selecting a row notifies ALL N rows (O(N))Calling
isSelectedoutside a reactive scope — returns the current value but doesn't subscribeUsing
createSelectorfor non-equality predicates — it's purpose-built for===matching; for ranges or filters, usecomputed()
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. Uselabel.peek()to readReading
label.peek()insideeffect()and expecting tracked re-runs — Cells don't integrate with effect tracking. Usesignal()if you need automatic dependency trackingUsing
cell()for ALL reactive state — only switch fromsignal()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) => TCreate 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 workCommon 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 } = storecaptures the value once, losing reactivity. Readstore.filterinside reactive scopesUsing
createStorefor simple scalar state — usesignal()for primitives;createStoreadds proxy overhead that only pays off for nested objectsExpecting 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 responseCommon mistakes
Forgetting
dispose()for resources outside an EffectScope — the internal source-tracking effect runs forever, leaking memory and unbounded fetch calls on source changesCalling
refetch()afterdispose()— silently no-ops; check disposed state on your end if neededReading
data()without checkingloading()/error()— undefined values flow through; gate the read on those signalsExpecting an in-flight response to update the resource AFTER
dispose()— the response is discarded by design (stale-id check),loadingmay stay frozen at its dispose-time valueReading signals INSIDE the fetcher and expecting tracked re-runs — only
source()is tracked; signals read insidefetcherare read once per call without subscription
See also: signal · effect · effectScope
reconcile function
<T extends object>(source: T, target: T) => voidSurgically 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 firesCommon mistakes
Passing a non-store as
target—reconcilerequires acreateStoreproxy; for plain objects, just assignExpecting 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 viaby)Using
reconcileinside 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) => booleanType 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
isStoreto detect ANY proxy — it's specific to Pyreon's store proxies. Other proxies returnfalseCalling on
null/undefinedand expecting a throw — null-safe; returnsfalse
See also: createStore · reconcile
shallowReactive function
<T extends object>(initial: T) => TCreate 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
createStoreif 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)andshallowReactive({ wrapper: raw })produce DIFFERENT proxies (separate caches). Pick one shape per data flow
See also: createStore · markRaw
markRaw function
<T extends object>(value: T) => TMark 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 receiverCommon 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 mutatesobjand 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) => TExecute 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
untrackas the default — signals should be tracked by default;untrackis the escape hatch for specific optimization or loop-prevention cases
See also: signal · effect
effectScope function
() => EffectScopeCreate 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 disposedCommon mistakes
Forgetting
scope.stop()— effects leak for the lifetime of the program; same shape as forgettingdispose()on a top-leveleffect()Creating effects outside
runInScope(fn)and expecting them to be tracked — effects must run during the synchronous body ofrunInScopeto register with the scopeStopping a scope that has pending updates — in-flight microtasks may still fire
onUpdatehooks; design for idempotency or checkisActivebefore writes
See also: effect · getCurrentScope · onScopeDispose
onScopeDispose function
(fn: () => void) => voidRegister 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 uncertainExpecting the callback to run on EFFECT cleanup —
onScopeDisposefires only onscope.stop(). For per-effect cleanup, useonCleanup()inside the effect body or return a cleanup function from itUsing outside
runInScopeand 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 | nullReturns 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 — returnsnull. Handle the no-scope case explicitlyUsing
getCurrentScope()as a substitute foreffectScope()— it returns the AMBIENT scope, not a fresh one
See also: effectScope · setCurrentScope
setCurrentScope function
(scope: EffectScope | null) => voidLow-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
setCurrentScopeinstead ofrunInScopein user code — the safe API isrunInScope
See also: effectScope · getCurrentScope
onSignalUpdate function
(listener: (event: { signal, name, prev, next, stack, timestamp }) => void) => () => voidRegister 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 listenerCommon mistakes
Leaving
onSignalUpdateregistered in production — fires on EVERY signal write, even hot-path internal ones. Always dispose when doneThrowing inside the listener — corrupts the signal's notification flow (the listener fires after
_vis updated but before subscribers are notified). Wrap your handler in try/catchExpecting 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: 2Common mistakes
Calling
inspectSignalin production — produces console noise. Gate calls behindif (import.meta.env.DEV)or__DEV__
See also: onSignalUpdate · why
why function
() => voidToggle 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 disarmedUsing
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 logicTreating 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) => voidRegister 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 crashingCommon mistakes
Calling
setErrorHandlermultiple times and expecting all to fire — the second call REPLACES the first. Compose multiple handlers manually if you need a chainThrowing 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(): booleanOpt-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 clearedCommon 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 graphCalling it in production for app logic — the whole bridge is dev-gated and tree-shaken;
getReactiveGraph()returns an empty graph in prod buildsAssuming it tracks compiler-emitted DOM bindings — only user
signal()/computed()/effect()are registered;renderEffect/_bindplumbing 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.valuefor 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 valueExpecting 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): stringAuto-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-signalinsight 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): stringAnswers "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 ← explainedCommon mistakes
Trusting the chain across UNRELATED rapid interactions — reconstruction is exact for one synchronous cascade; interactions within the ~16ms cluster window can blur.
rootReached: falsemeans older fires aged out of the ring buffer.Calling it in production — the reactive registry is tree-shaken when
NODE_ENV === "production", sogetUpdateCausereturns null (nothing tracked).Expecting timeline order to be causal order — it is NOT (a lazy computed recomputes during its subscriber's read).
getUpdateCauseuses 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 customsethandler — the signal's internal_vnever 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_vproperty — the compiler's_bindTextfast path readssource._vdirectly to skip the function call; without it, text bindings render empty even though()works.Capturing the
basesignal 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
wrapSignalto add tracking to a signal that's written from multiple code paths without coordinating in the customsethandler — the side effect runs on EVERY write, including batch-deferred ones; if other code writes directly tobaseit bypasses the facade.Expecting the custom
updateoption to be optional AND have a default — if not provided, it defaults toset(fn(base.peek())), which may not match your intent ifsethas 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
sethandler not calling through tobase.set()— the wrapped signal's value never updates internally, all reads stall.Providing
updatethat doesn't eventually callset— reads won't reflect the update unlesssetis involved in the chain.Expecting the default
updateto coalesce rapid-fire calls — it callsset()directly, so ifsethas side effects (persist, emit), they fire on every update call without coalescing.Using the same
optionsobject for multiplewrapSignal()calls on different bases — thesetandupdateclosures capture the wrongbasereference if reused.
See also: wrapSignal · Signal
startReactiveCoverage function
() => voidBegin 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
() => ReactiveCoverageReportSnapshot 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 }) => stringRender 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 : neverUnwrap 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 tonever; the input must be the SIGNAL type (typeof mySignal), not the value type itselfPassing 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) => stringresolves toneverby design
See also: signal · ComputedValue · MaybeAccessor
ComputedValue type
type ComputedValue<C> = C extends () => infer T ? T : neverUnwrap 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> // numberCommon mistakes
ComputedValue<ReturnType<typeof computed>>gymnastics — passtypeof totaldirectlyExpecting 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 sourceResolving 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 trackingUsing it for a FUNCTION-valued parameter (
MaybeAccessor<() => void>) — the two union arms are runtime-ambiguous; use a dedicated options field insteadDiscriminating 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 : AResolve 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>> // booleanCommon mistakes
Confusing it with
SignalValue—AccessorReturn<number>isnumber(pass-through) whileSignalValue<number>isnever(strict: input must be callable)
See also: MaybeAccessor · SignalValue
registerSingleton function
(pkg: string, version: string, location: string) => voidFail-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 exportsCommon 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 reportRegistering 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.urlat the top of the real entry moduleWrapping 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) => TA 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 isT extends object; primitives cannot be mutated in place, so cross-instance updates would not propagate. Return a mutable object/Map/SetUsing a non-globally-unique
key—Symbol.foris 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 | nullRead 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 boundaryForgetting to capture this BEFORE an async gap (an
await import(), asetTimeout) in code that will calluseContext()later — by the time the deferred code runs, the owner active at capture time is gone unless you threaded it throughrunWithContextOwner
See also: runWithContextOwner · setContextOwner · getCurrentScope
runWithContextOwner function
<T>(owner: EffectScope | null, fn: () => T) => TRun 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 —runWithContextOwnerrestores via try/finally even iffnthrows; a hand-written set/restore pair leaks the owner on an exceptionCapturing 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 | nullLow-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 subsequentuseContext()calls resolving against the wrong ancestor chain until something else happens to reset itUsing 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
.valuegetters (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()andcomputed()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 trackxwhencond()is true.
Standalone:
@pyreon/reactivityhas zero dependencies. Use it in Node/Bun scripts, edge workers, or any JavaScript environment without pulling in the rest of the framework.@pyreon/coreand@pyreon/runtime-dombuild on it but are not required.