@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.
Features
signal<T>() — callable function with .set(), .update(), .trigger()
computed<T>() — auto-tracked memoized derivation
effect() / renderEffect() — side-effects with auto-tracking
batch() / nextTick() — write-grouping + flush awaiter
onCleanup() — register cleanup inside effects
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, memoized
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 memoized derived value. |
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 function inside an effect() or renderEffect(). |
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 |
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 memoized derived value. Dependencies auto-tracked on each evaluation — no dependency array needed (unlike React useMemo). Only recomputes when a tracked signal actually changes. Custom equals function prevents downstream effects from firing on structurally-equal updates (default: Object.is).
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 to skip downstream updates on structurally-equal values (default Object.is). |
Returns Computed<T> — A read-only callable — call to read the memoized value; recomputes lazily only when a dependency changes.
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 updatesReasoning 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 function inside an effect() or renderEffect(). Runs before each re-execution of the effect (when dependencies change) and once on final dispose. Equivalent to returning a cleanup function from the effect body — both forms work, onCleanup is useful when you need to register cleanup at a different point than the end of the body.
Parameters
| Parameter | Type | Description |
|---|---|---|
fn | () => void | Cleanup callback — runs before the owning effect/computed re-runs and once on final dispose. |
Returns void — Nothing — registers the cleanup with the current reactive owner.
Example
effect(() => {
const handler = () => console.log(count())
window.addEventListener("resize", handler)
onCleanup(() => window.removeEventListener("resize", handler))
})Common mistakes
Using
onCleanupoutside an effect — it only works insideeffect()orrenderEffect()bodyConfusing with
onUnmount—onCleanupis for effects,onUnmountis for component lifecycle
See also: effect
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
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.