pyreon

@pyreon/reactivity is the foundation of Pyreon's reactivity system. It provides signals, computed values, effects, stores, and other primitives that enable fine-grained, automatic dependency tracking without a virtual DOM. Every reactive update in Pyreon flows through these primitives.

@pyreon/reactivitystable

Installation

npm install @pyreon/reactivity
bun add @pyreon/reactivity
pnpm add @pyreon/reactivity
yarn add @pyreon/reactivity

Signals

A signal is a reactive container for a value. Reading a signal inside an effect automatically subscribes that effect to the signal. When the signal's value changes, all subscribed effects re-run. Signals use Object.is for equality -- setting a signal to the same value is a no-op.

import { signal } from '@pyreon/reactivity'

const count = signal(0)

// Read the value by calling the signal as a function
console.log(count()) // 0

// Set a new value
count.set(5)

// Update based on the current value
count.update((n) => n + 1) // now 6
Signals — read, write, react

Signal Interface

interface Signal<T> {
  /** Read the current value and register a reactive dependency. */
  (): T
  /** Read the current value WITHOUT registering a reactive dependency. */
  peek(): T
  /** Set a new value. No-op if the new value is identical (Object.is). */
  set(value: T): void
  /** Update the value based on the current value. */
  update(fn: (current: T) => T): void
  /** Subscribe a static listener directly. Returns a disposer function. */
  subscribe(listener: () => void): () => void
  /** Debug name for devtools and logging. */
  label: string | undefined
  /** Returns a snapshot of the signal's debug info. */
  debug(): SignalDebugInfo<T>
}

interface SignalDebugInfo<T> {
  name: string | undefined
  value: T
  subscriberCount: number
}

Signal Options

const name = signal('Alice', { name: 'userName' })
console.log(name.debug())
// { name: "userName", value: "Alice", subscriberCount: 0 }

// You can also set the label after creation
name.label = 'currentUserName'

The name option sets a debug label that appears in devtools, debug() output, and signal tracing.

SignalOptions
PropTypeDefaultDescription
namestringDebug label for devtools and signal tracing

Peeking Without Tracking

Use peek() to read a signal's value without creating a reactive dependency. This is essential inside effects when you need to read a value without re-running when it changes.

const count = signal(0)
const other = signal(100)

effect(() => {
  // This effect depends on `count`, but NOT on `other`
  console.log(count(), other.peek())
})

other.set(200) // effect does NOT re-run
count.set(1) // effect re-runs, reads other.peek() which is 200

Direct Subscriptions

For cases where you need a fixed subscription without the overhead of an effect (no dependency tracking, no cleanup/re-tracking on each run), use subscribe():

const count = signal(0)

const unsubscribe = count.subscribe(() => {
  console.log('count changed to:', count.peek())
})

count.set(1) // logs "count changed to: 1"
count.set(2) // logs "count changed to: 2"
unsubscribe() // removes the subscription
count.set(3) // nothing logged

Signal Internals

Signals are implemented as function objects with state stored as properties. Only one closure is allocated per signal (the read function). Methods like peek, set, update, and subscribe are shared implementations assigned to every signal instance, not per-signal closures. This design keeps memory overhead minimal while maintaining a clean API.

Derived Signal Patterns

Signal Arrays

const items = signal<string[]>([])

// Add an item
items.update((arr) => [...arr, 'new item'])

// Remove by index
items.update((arr) => arr.filter((_, i) => i !== 2))

// Sort
items.update((arr) => [...arr].sort())

Signal Maps

const users = signal(new Map<string, User>())

// Add a user
users.update((map) => {
  const next = new Map(map)
  next.set(user.id, user)
  return next
})

// Delete a user
users.update((map) => {
  const next = new Map(map)
  next.delete(userId)
  return next
})

Derived State Trees

const firstName = signal('Alice')
const lastName = signal('Smith')
const fullName = computed(() => `${firstName()} ${lastName()}`)
const greeting = computed(() => `Hello, ${fullName()}!`)
const uppercaseGreeting = computed(() => greeting().toUpperCase())

// Changing firstName propagates through the chain:
// firstName -> fullName -> greeting -> uppercaseGreeting
firstName.set('Bob')
console.log(uppercaseGreeting()) // "HELLO, BOB SMITH!"

Computed

A computed value derives from other reactive sources. It is lazy by default -- it only recalculates when read, and only if its dependencies have changed.

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

const firstName = signal('Alice')
const lastName = signal('Smith')

const fullName = computed(() => `${firstName()} ${lastName()}`)

console.log(fullName()) // "Alice Smith"
firstName.set('Bob')
console.log(fullName()) // "Bob Smith"
Computed — derived values

Computed Interface

interface Computed<T> {
  /** Read the computed value (tracked). Re-evaluates if dirty. */
  (): T
  /** Remove this computed from all its reactive dependencies. */
  dispose(): void
}

Custom Equality

By default, a computed notifies downstream whenever any dependency changes. Use the equals option to suppress updates when the derived value has not meaningfully changed. This is especially useful for derived arrays and objects:

const items = signal([3, 1, 4, 1, 5])

const sorted = computed(
  () =>
    items()
      .slice()
      .sort((a, b) => a - b),
  {
    equals: (prev, next) => prev.length === next.length && prev.every((v, i) => v === next[i]),
  },
)

// Downstream effects only fire when the sorted result actually changes
effect(() => {
  console.log('Sorted:', sorted())
})

// This triggers the effect (sorted output changes)
items.set([5, 3, 1])

// This does NOT trigger the effect (sorted output is the same: [1, 3, 5])
items.set([1, 5, 3])

With equals, the computed eagerly re-evaluates when dependencies change, but only notifies downstream effects if the equality check returns false.

Disposing

Computeds are automatically disposed when their parent EffectScope stops. You can also dispose them manually:

const doubled = computed(() => count() * 2)
// Later:
doubled.dispose()

After disposal, the computed no longer reacts to dependency changes and is removed from all subscriber lists.

Dynamic Dependency Tracking in Computed

Computed values support dynamic dependencies -- the set of dependencies can change between evaluations:

const showDetails = signal(false)
const summary = signal('Brief')
const details = signal('Full details here')

const display = computed(() => {
  if (showDetails()) {
    return details() // tracked only when showDetails is true
  }
  return summary() // tracked only when showDetails is false
})

Computed Chains

Computeds can depend on other computeds, forming a chain:

const price = signal(100)
const quantity = signal(2)
const taxRate = signal(0.08)

const subtotal = computed(() => price() * quantity())
const tax = computed(() => subtotal() * taxRate())
const total = computed(() => subtotal() + tax())

console.log(total()) // 216

// Only quantity changed, but subtotal, tax, and total all update
quantity.set(3)
console.log(total()) // 324

Effects

An effect runs a function and automatically re-runs it whenever any signal or computed it reads changes. Effects run synchronously on creation and re-run synchronously on each dependency change.

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

const count = signal(0)

const e = effect(() => {
  console.log('Count is:', count())
})
// Immediately logs "Count is: 0"

count.set(1) // logs "Count is: 1"
count.set(2) // logs "Count is: 2"

e.dispose() // stops the effect
count.set(3) // nothing logged
Effects — side effects on signal change

Effect Interface

interface Effect {
  dispose(): void
}

Dynamic Dependencies

Effects support dynamic dependency tracking. Dependencies are re-evaluated on each run, so conditional reads work correctly:

const showDetails = signal(false)
const details = signal('hidden content')

effect(() => {
  if (showDetails()) {
    console.log(details()) // only tracked when showDetails is true
  } else {
    console.log('Details hidden')
  }
})

details.set('new content') // effect does NOT re-run (not currently tracked)
showDetails.set(true) // effect re-runs, now tracks details
details.set('updated') // effect re-runs (now tracked)

Nested Effect Patterns

Effects can create other effects. The inner effect is independent and must be disposed separately:

const enabled = signal(true)
const count = signal(0)

const outer = effect(() => {
  if (enabled()) {
    // This inner effect is created fresh each time enabled() changes to true
    const inner = effect(() => {
      console.log('Count:', count())
    })
    // Important: clean up the inner effect when the outer re-runs
    // In practice, use EffectScope for automatic cleanup
  }
})

Effect Cleanup

Use onCleanup from @pyreon/core inside an effect to register a cleanup function. The cleanup runs before each re-execution and on final disposal:

import { onCleanup } from '@pyreon/core'

effect(() => {
  const q = query()
  const controller = new AbortController()
  fetch(`/search?q=${q}`, { signal: controller.signal })

  onCleanup(() => controller.abort()) // runs before next re-execution
})

Alternatively, use watch when you need old/new values along with cleanup:

watch(
  () => query(),
  (q) => {
    const controller = new AbortController()
    fetch(`/search?q=${q}`, { signal: controller.signal })
    return () => controller.abort() // cleanup runs before next invocation
  },
)

Conditional Tracking Patterns

const logLevel = signal<'debug' | 'info' | 'error'>('info')
const debugData = signal({ calls: 0, lastArgs: null })
const errorCount = signal(0)

effect(() => {
  const level = logLevel()
  if (level === 'debug') {
    // Only tracks debugData when in debug mode
    console.log('Debug:', debugData())
  } else if (level === 'error') {
    // Only tracks errorCount when in error mode
    console.log('Errors:', errorCount())
  }
})

Error Handling

Unhandled errors inside effects are caught and reported via a configurable error handler:

import { setErrorHandler } from '@pyreon/reactivity'

setErrorHandler((err) => {
  myErrorReporter.capture(err)
})

The default error handler logs to console.error. This ensures errors inside effects are never silently swallowed.

renderEffect

A lightweight effect variant designed for DOM render bindings. It skips EffectScope registration, error handler overhead, and onUpdate notification. Returns a dispose function directly (not an Effect object, saving one allocation):

import { renderEffect } from '@pyreon/reactivity'

const dispose = renderEffect(() => {
  el.textContent = String(count())
})

// Later:
dispose()

renderEffect stores its dependencies in a local array instead of the global WeakMap, saving approximately 200ns per effect creation and disposal compared to effect().

_bind

A compiler-internal static-dep binding. Tracks dependencies only on the first run and never re-tracks on subsequent runs. This makes re-runs faster because they skip cleanup, re-tracking, and tracking context save/restore entirely.

import { _bind } from '@pyreon/reactivity'

const dispose = _bind(() => {
  el.className = className()
})

This is used by the Pyreon compiler for template expressions where dependencies are known to be static (they never change between runs).

Batch

Batch multiple signal updates into a single notification pass. Effects that depend on multiple updated signals only run once after the batch completes, not once per signal change.

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

const first = signal('Alice')
const last = signal('Smith')

effect(() => {
  console.log(`${first()} ${last()}`)
})
// logs "Alice Smith"

// Without batch: would log twice ("Bob Smith" then "Bob Jones")
// With batch: logs once with final values
batch(() => {
  first.set('Bob')
  last.set('Jones')
})
// logs "Bob Jones" (once)

Nested Batches

Batches can be nested. Notifications only flush after the outermost batch completes:

batch(() => {
  first.set('Alice')
  batch(() => {
    last.set('Johnson')
    // No flush yet -- inner batch completed but outer is still open
  })
  first.set('Bob')
  // No flush yet
})
// Now the outermost batch ends -- effects run once with final values

Batch with Return Values

Batch returns the result of the callback:

const result = batch(() => {
  count.set(10)
  return count.peek() // 10
})

nextTick

Returns a Promise that resolves after all pending microtasks have flushed. Useful for reading the DOM after signal updates have settled:

import { nextTick } from '@pyreon/reactivity'

count.set(42)
await nextTick()
// DOM is now up-to-date
console.log(el.textContent) // "42"

nextTick is implemented as a simple queueMicrotask wrapper:

nextTick().then(() => {
  // All synchronous reactive updates from the current task are done
})

Watch

Watch a reactive source and run a callback whenever it changes. More explicit than effect -- you specify exactly what to watch and get both old and new values. The callback also supports returning a cleanup function.

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

const userId = signal(1)

const stop = watch(
  () => userId(),
  async (newId, oldId) => {
    console.log(`Changed from ${oldId} to ${newId}`)
    const data = await fetch(`/api/user/${newId}`)
    setUser(await data.json())
  },
)

userId.set(2) // logs "Changed from 1 to 2"

// Later:
stop()

Watch Interface

function watch<T>(
  source: () => T,
  callback: (newVal: T, oldVal: T | undefined) => void | (() => void),
  opts?: WatchOptions,
): () => void

interface WatchOptions {
  /** If true, call the callback immediately with the current value on setup. */
  immediate?: boolean
}

Immediate Option

const count = signal(0)

const stop = watch(
  () => count(),
  (value, prev) => console.log(`${prev} -> ${value}`),
  { immediate: true },
)
// Immediately logs "undefined -> 0"

count.set(5) // logs "0 -> 5"

Cleanup Function

The callback may return a cleanup function that runs before each re-invocation and when the watcher is stopped. This is the recommended pattern for cancellable async operations:

const stop = watch(
  () => searchQuery(),
  (query) => {
    const controller = new AbortController()

    fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    })
      .then((r) => r.json())
      .then((data) => results.set(data))
      .catch(() => {}) // ignore abort errors

    return () => controller.abort() // cancel previous request
  },
)

Watch vs Effect

Use watch when you need:

  • Old and new values

  • A cleanup function between runs

  • Explicit control over what is watched (the source expression)

Use effect when you need:

  • Simple reactive side effects

  • Auto-tracked dependencies without specifying a source

// effect: auto-tracks everything read inside
effect(() => {
  document.title = `${count()} items`
})

// watch: explicit source, old/new values, cleanup
watch(
  () => userId(),
  (newId, oldId) => {
    console.log(`User changed from ${oldId} to ${newId}`)
    const cleanup = setupUserSubscription(newId)
    return cleanup
  },
)

Cell

A lightweight reactive cell -- a class-based alternative to signal(). Cells use a single object allocation (one new Cell()) instead of signal's function-based approach, making them slightly cheaper to create. However, cells are not callable as getters, so they do not participate in automatic effect dependency tracking.

Use cells when you need reactive state with manual subscriptions rather than automatic tracking. They are ideal for keyed list reconcilers and internal framework plumbing.

import { cell, Cell } from '@pyreon/reactivity'

const count = cell(0)

count.peek() // read the value
count.set(5) // set a new value
count.update((n) => n + 1)

// Subscribe to changes (returns unsubscribe function)
const unsub = count.subscribe(() => {
  console.log('changed to:', count.peek())
})

// Fire-and-forget subscription (no unsubscribe returned, saves 1 allocation)
count.listen(() => {
  console.log('changed!')
})

Cell Interface

class Cell<T> {
  peek(): T
  set(value: T): void
  update(fn: (current: T) => T): void
  listen(listener: () => void): void
  subscribe(listener: () => void): () => void
}

function cell<T>(value: T): Cell<T>

Single-Listener Fast Path

Cells optimize for the common case of a single listener. When only one subscriber exists, the cell stores it directly (no Set allocation). When a second subscriber is added, the cell promotes to a Set:

const c = cell('hello')
c.listen(fn1) // stored as single listener -- no Set
c.listen(fn2) // promotes to Set({ fn1, fn2 })

Cell vs Signal

FeatureSignalCell
Allocation1 closure (function object with properties)1 object (class instance)
Automatic trackingYes -- signal() call registers dependencyNo -- must use subscribe() or listen()
Use inside effectsYesNot directly (use subscribe)
MethodsShared via assignmentOn prototype
Best forGeneral reactive stateInternal framework state, list item labels

createStore

A deep reactive Proxy store. Wraps a plain object or array in a Proxy that creates a fine-grained signal for every property. Direct mutations trigger only the signals for the mutated properties -- not the entire tree.

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

const state = createStore({
  count: 0,
  user: { name: 'Alice', age: 30 },
  items: [{ id: 1, text: 'hello' }],
})

effect(() => console.log(state.count)) // tracks state.count only
state.count++ // only the count effect re-runs
state.user.name = 'Bob' // only name-tracking effects re-run
state.items[0].text = 'world' // only text-tracking effects re-run

isStore(state) // true
isStore({}) // false

Store Features

  • Fine-grained: each property gets its own signal, so mutations only notify effects that read the specific changed property

  • Deep reactivity: nested objects and arrays are transparently wrapped in proxies on access

  • Array support: push, pop, splice, and direct index assignment all trigger reactive updates; array length changes are tracked via a dedicated signal

  • Proxy-based: mutate properties directly with standard JavaScript syntax

  • Proxy caching: each raw object gets at most one proxy (cached in a WeakMap)

Complex Nested Mutations

const state = createStore({
  user: {
    profile: {
      name: 'Alice',
      address: {
        city: 'Portland',
        state: 'OR',
      },
    },
    preferences: {
      theme: 'dark',
      notifications: true,
    },
  },
})

// Deep mutation -- only city-tracking effects re-run
state.user.profile.address.city = 'Seattle'

// Nested object replacement -- creates new proxy for the new object
state.user.preferences = { theme: 'light', notifications: false }

Array Operations

const state = createStore({
  items: [
    { id: 1, name: 'Item 1' },
    { id: 2, name: 'Item 2' },
  ],
})

// Push -- triggers length signal and adds index signal
state.items.push({ id: 3, name: 'Item 3' })

// Pop -- triggers length signal
state.items.pop()

// Splice -- triggers length and affected index signals
state.items.splice(0, 1) // remove first item

// Direct index assignment
state.items[0].name = 'Updated'

// Sort in place (modifies indices)
state.items.sort((a, b) => a.name.localeCompare(b.name))

// Filter and replace
const filtered = state.items.filter((item) => item.id !== 2)
state.items.length = 0
filtered.forEach((item) => state.items.push(item))

Delete Properties

const state = createStore({ temp: 'value', keep: 'this' })
delete state.temp // signal for "temp" fires with undefined, then is removed

Store with Effects

const appState = createStore({
  todos: [] as Array<{ id: number; text: string; done: boolean }>,
  filter: 'all' as 'all' | 'active' | 'done',
})

// This effect only re-runs when filter changes
effect(() => {
  console.log('Filter is:', appState.filter)
})

// This effect only re-runs when todos array length changes
effect(() => {
  console.log('Todo count:', appState.todos.length)
})

// This effect only re-runs when the first todo's text changes
effect(() => {
  if (appState.todos.length > 0) {
    console.log('First todo:', appState.todos[0].text)
  }
})

// Mutate -- only the relevant effects fire
appState.todos.push({ id: 1, text: 'Buy milk', done: false })
appState.todos[0].done = true // none of the above effects fire (they don't read .done)

shallowReactive

Vue 3 parity helper for store-shaped reactivity that does not recurse into nested objects. Top-level property writes (and replacements) notify subscribers; nested object mutations do not. Use this when a large object graph contains data that doesn't need deep tracking — e.g. caches, large records, externally-managed shapes.

import { shallowReactive, effect } from '@pyreon/reactivity'

const store = shallowReactive({ user: { name: 'Alice' }, count: 0 })

effect(() => {
  console.log(store.user.name, store.count)
})

store.count = 1 // ✓ effect re-runs (top-level write)
store.user.name = 'Bob' // ✗ effect does NOT re-run (nested mutation)
store.user = { name: 'Bob' } // ✓ effect re-runs (top-level reference replacement)

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

markRaw

Mark an object so createStore and shallowReactive will return it unwrapped (Vue 3 parity). Useful for class instances (Editor, Canvas, MapInstance), third-party objects, DOM nodes, or any shape that shouldn't be deeply proxied.

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

class Editor {
  /* … */
}
const ed = markRaw(new Editor()) // skips proxy
const state = createStore({ editor: ed, count: 0 })
state.editor // returns the original Editor instance, not a proxy

Marking is one-way — there is no unmarkRaw. Mark BEFORE the object enters a store; marking after wrap doesn't unwrap an existing proxy. Note that markRaw(obj) mutates obj in place (attaches a non-enumerable marker symbol) and returns the SAME reference — don't expect a different object.

For plain data objects where you just want to skip deep tracking, use shallowReactive instead — markRaw is for class instances and externally-managed shapes.

reconcile

Surgically diff new state into an existing createStore proxy. Instead of replacing the store root (which would trigger all downstream effects), reconcile walks both the new value and the store in parallel and only updates signals whose values actually changed.

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

const state = createStore({
  user: { name: 'Alice', age: 30 },
  items: [] as Array<{ id: number; text: string }>,
})

// API response arrives -- only changed properties trigger updates
reconcile({ user: { name: 'Alice', age: 31 }, items: [{ id: 1, text: 'Hello' }] }, state)
// Only state.user.age signal fires (name unchanged)
// state.items[0] is newly created

How Reconcile Works

  1. Objects: walks all keys in the source. For each key, if both source and target values are objects and the target is a store proxy, recurse. Otherwise, assign directly (the store proxy's set trap skips if Object.is equal). Keys present in the target but not in the source are deleted.

  2. Arrays: reconciles by index. Elements at the same index are recursively diffed rather than replaced wholesale. Excess old elements are trimmed by setting target.length.

reconcile with API Responses

const state = createStore({
  users: [] as User[],
  pagination: { page: 1, total: 0 },
})

async function fetchUsers(page: number) {
  const response = await fetch(`/api/users?page=${page}`)
  const data = await response.json()

  // Surgically update only what changed
  reconcile({ users: data.users, pagination: { page, total: data.total } }, state)
}

Key-Based Reconciliation

For arrays where items have stable IDs and may be reordered, combine reconcile with manual key matching for best results:

// Simple index-based reconcile (default behavior)
reconcile({ items: newItems }, state)

// For reorderable lists, consider using For + mapArray instead

reconcile Signature

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

Both source (the new data) and target (the store proxy) must be the same shape. source is a plain object; target is a createStore proxy.

createResource

Async data primitive. Reactively fetches data whenever the source signal changes. Handles loading state, errors, and request cancellation (stale requests are ignored) automatically.

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

const userId = signal(1)

const user = createResource(
  () => userId(),
  (id) => fetch(`/api/user/${id}`).then((r) => r.json()),
)

// Reactive signals:
user.data() // the fetched user (undefined while loading)
user.loading() // true while in flight
user.error() // last error, or undefined

// Manual refetch with current source value:
user.refetch()

Resource Interface

interface Resource<T> {
  /** The latest resolved value (undefined while loading or on error). */
  data: Signal<T | undefined>
  /** True while a fetch is in flight. */
  loading: Signal<boolean>
  /** The last error thrown by the fetcher, or undefined. */
  error: Signal<unknown>
  /** Re-run the fetcher with the current source value. */
  refetch(): void
}

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

Resource with Components

import { signal, createResource } from '@pyreon/reactivity'
import { Show, Switch, Match } from '@pyreon/core'

function UserProfile() {
  const userId = signal(1)

  const user = createResource(
    () => userId(),
    async (id) => {
      const res = await fetch(`/api/users/${id}`)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res.json()
    },
  )

  return (
    <div>
      <Switch>
        <Match when={user.loading}>
          <div class="skeleton">Loading...</div>
        </Match>
        <Match when={() => user.error() !== undefined}>
          <div class="error">
            Failed to load user: {() => String(user.error())}
            <button onClick={() => user.refetch()}>Retry</button>
          </div>
        </Match>
        <Match when={() => user.data() !== undefined}>
          <div class="profile">
            <h2>{() => user.data()!.name}</h2>
            <p>{() => user.data()!.email}</p>
          </div>
        </Match>
      </Switch>
    </div>
  )
}

Stale Request Handling

createResource uses a request ID counter to discard stale responses. If source() changes while a previous fetch is in flight, the old response is ignored when it resolves:

const searchQuery = signal('react')
const results = createResource(
  () => searchQuery(),
  (q) => fetch(`/api/search?q=${q}`).then((r) => r.json()),
)

// User types quickly:
searchQuery.set('reac')
searchQuery.set('react')
searchQuery.set('reacti')
searchQuery.set('reactiv')
searchQuery.set('reactive')
// Only the "reactive" response is stored in data -- earlier responses are discarded

Resource with Dependent Sources

const category = signal('electronics')
const page = signal(1)

const products = createResource(
  // Source reads both -- refetches when either changes
  () => ({ category: category(), page: page() }),
  ({ category, page }) => fetch(`/api/products?cat=${category}&page=${page}`).then((r) => r.json()),
)

createSelector

Create an O(1) equality selector for efficient list selection. Unlike a plain () => source() === value comparison (which re-runs all subscribers on every change), createSelector only triggers the two affected subscribers -- the deselected and newly selected items.

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

const selectedId = signal(1)
const isSelected = createSelector(() => selectedId())

// In each list row -- only 2 effects fire per selection change:
effect(() => {
  const active = isSelected(row.id)
  row.el.classList.toggle('selected', active)
})

How createSelector Works

Internally, createSelector maintains a Map<T, Set<listener>>. Each call to isSelected(value) registers a subscription in the bucket for that value. When the source changes from old to new, only the old bucket and new bucket are notified -- O(1) regardless of list size.

List Selection Example

function SelectableList(props: { items: () => Item[] }) {
  const selectedId = signal<number | null>(null)
  const isSelected = createSelector(() => selectedId())

  return (
    <ul>
      <For each={props.items} by={(item) => item.id}>
        {(item) => (
          <li
            class={() => (isSelected(item.id) ? 'item selected' : 'item')}
            onClick={() => selectedId.set(item.id)}
          >
            {item.name}
          </li>
        )}
      </For>
    </ul>
  )
}

Multi-Select with createSelector

For multi-select, you can use a signal holding a Set and check membership:

const selectedIds = signal(new Set<number>())

// This is O(n) per change -- for large lists, consider multiple createSelector instances
const isSelected = (id: number) => selectedIds().has(id)

// Toggle selection
function toggleSelect(id: number) {
  selectedIds.update((set) => {
    const next = new Set(set)
    if (next.has(id)) next.delete(id)
    else next.add(id)
    return next
  })
}

selector.subscribe(value, updater) — effect-free fast path

For the canonical <For> + createSelector selection-bound pattern (className / textContent toggling per row), Selector<T> exposes a direct subscription that skips the renderEffect machinery entirely:

const isSelected = createSelector(() => selectedId())

// In each row's template:
const dispose = isSelected.subscribe(row.id, (matches) => {
  rowEl.className = matches ? 'selected' : ''
})
// dispose() unsubscribes when the row unmounts

Equivalent to effect(() => updater(isSelected(row.id))) but:

  • ~2 allocations per row (one Set.add + one dispose closure) vs ~5 with effect(...) (deps[] + run closure + dispose closure + scope wrapper + trackedFn closure)

  • No renderEffect setup — the selector's source effect stores the updater directly in a per-key bound bucket and calls it with the resolved boolean on selection change

  • Direct call per fire with pre-resolved boolean instead of withTracking + selector lookup + Object.is + ternary

The @pyreon/compiler auto-promotes the canonical JSX shapes to .subscribe — you don't need to call it directly:

// Author writes the natural shape:
<For each={rows} by={(r) => r.id}>
  {(row) => (
    <tr class={() => isSelected(row.id) ? 'selected' : ''}>
      <td>{() => isSelected(row.id) ? '✓' : ''}</td>
      ...
    </tr>
  )}
</For>

// Compiler emits the effect-free path automatically. See docs/compiler.md
// "Auto-promoted Fast Paths" for the bail catalog.

Signature:

interface Selector<T> {
  (value: T): boolean
  subscribe(value: T, updater: (matches: boolean) => void): () => void
  dispose(): void
}

The updater fires synchronously with the initial state at subscription time, then once per selection change crossing this key. Post-dispose(), subsequent .subscribe() calls invoke the updater with the last-known state and return a no-op cleanup.

createSelector Signature

function createSelector<T>(source: () => T): Selector<T>

EffectScope

An EffectScope automatically tracks effects and computeds created within it and disposes them all at once. This is used internally by the component system (each component gets its own scope) but can also be used standalone for managing reactive subscriptions outside of components.

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

const scope = effectScope()

// Effects/computeds created while a scope is current are auto-tracked
setCurrentScope(scope)

const count = signal(0)
const doubled = computed(() => count() * 2)
effect(() => console.log(doubled()))

setCurrentScope(null)

// Dispose everything at once
scope.stop()
// All effects and computeds are cleaned up

Scope API

class EffectScope {
  /** Register an effect/computed to be disposed when this scope stops. */
  add(e: { dispose(): void }): void
  /** Run a function within this scope (effects auto-tracked). */
  runInScope<T>(fn: () => T): T
  /** Register a callback to run after any reactive update in this scope. */
  addUpdateHook(fn: () => void): void
  /** Called by effects after non-initial re-runs to schedule update hooks. */
  notifyEffectRan(): void
  /** Dispose all tracked effects and hooks. */
  stop(): void
}

runInScope

Use runInScope to create effects within a scope after the initial setup phase. This is essential for effects created in onMount callbacks:

const scope = effectScope()

// Later (e.g., in an onMount callback):
scope.runInScope(() => {
  // This effect belongs to the scope and will be disposed with it
  effect(() => console.log(count()))
})

onScopeDispose

Vue 3 parity helper. Register a callback to run when the current scope stops:

import { effectScope, onScopeDispose } from '@pyreon/reactivity'

const scope = effectScope()
scope.runInScope(() => {
  const ws = new WebSocket('wss://api/feed')
  onScopeDispose(() => ws.close())
})

// Later — when the scope tears down, the WebSocket closes automatically:
scope.stop()

Equivalent to getCurrentScope()?.add({ dispose: fn }) but reads more naturally at the call site. Calling onScopeDispose outside an active scope emits a dev-mode warning and is a no-op in production.

Component Lifecycle Integration

Internally, each Pyreon component gets its own EffectScope. When the component unmounts, scope.stop() disposes all effects and computeds created during setup:

function MyComponent() {
  // These are auto-tracked by the component's scope:
  const count = signal(0)
  const doubled = computed(() => count() * 2)
  effect(() => console.log(doubled()))

  // When MyComponent unmounts:
  // - The effect is disposed
  // - The computed is disposed
  // - All subscriptions are cleaned up
  return <div>{doubled()}</div>
}

Standalone Scope for Non-Component Code

// Use a scope for reactive code outside of components
const scope = effectScope()

function startTracking() {
  scope.runInScope(() => {
    const position = signal({ x: 0, y: 0 })

    effect(() => {
      sendAnalytics('cursor', position())
    })

    const onMove = (e: MouseEvent) => {
      position.set({ x: e.clientX, y: e.clientY })
    }
    window.addEventListener('mousemove', onMove)
    // Register cleanup so `scope.stop()` removes the listener too.
    // Without this, the listener leaks past `stopTracking()` even though
    // the effect inside the scope is disposed.
    onCleanup(() => window.removeEventListener('mousemove', onMove))
  })
}

function stopTracking() {
  scope.stop() // all effects disposed + listeners removed via onCleanup
}

Update Hooks

Scopes can notify registered update hooks after reactive effects re-run. The notification happens via microtask so all synchronous effects settle first:

const scope = effectScope()

scope.addUpdateHook(() => {
  console.log('A reactive update occurred in this scope')
})

scope.runInScope(() => {
  const count = signal(0)
  effect(() => void count()) // reads count

  count.set(1) // triggers the effect, which triggers notifyEffectRan,
  // which schedules the update hook via microtask
})

runUntracked / untrack

Run a function without registering any reactive dependencies. Useful inside effects when you need to read a signal without subscribing to it. untrack is a shorter alias for runUntracked -- both are identical.

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

const a = signal(1)
const b = signal(2)

effect(() => {
  const aVal = a() // tracked
  const bVal = runUntracked(() => b()) // NOT tracked
  console.log(aVal + bVal)
})

b.set(10) // effect does NOT re-run
a.set(2) // effect re-runs, reads b's current value (10), logs 12

Common Use Cases

// Read config without tracking
effect(() => {
  const data = fetchedData()
  const config = runUntracked(() => appConfig())
  process(data, config)
})

// One-time snapshot
effect(() => {
  const current = count()
  const snapshot = runUntracked(() => ({
    timestamp: Date.now(),
    otherState: otherSignal(),
  }))
  logChange(current, snapshot)
})

Debug Utilities

Development-only tools for tracing signal updates and understanding reactive behavior. All utilities are tree-shakeable and compile away in production when unused.

onSignalUpdate

Register a listener that fires on every signal write. Returns a dispose function.

import { onSignalUpdate } from '@pyreon/reactivity'

const dispose = onSignalUpdate((event) => {
  console.log(`${event.name ?? 'anonymous'}: ${event.prev} -> ${event.next}`)
  console.log('Stack:', event.stack)
  console.log('Time:', event.timestamp)
})

// Later: stop tracing
dispose()

why()

Trace the next signal update. Logs which signals fire and what changed. Call before triggering a state change to see what updates and why:

import { why } from '@pyreon/reactivity'

why()
count.set(5)
// Console: [pyreon:why] "count": 3 -> 5 (2 subscribers)

why() auto-disposes after the current microtask, so it only captures the synchronous batch of updates.

inspectSignal

Print a signal's current state to the console:

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

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

Real-World Reactive Patterns

Reactive Form State

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

function createFormField<T>(initial: T, validate: (v: T) => string | null) {
  const value = signal(initial)
  const touched = signal(false)
  const error = computed(() => (touched() ? validate(value()) : null))
  const valid = computed(() => error() === null)

  return {
    value,
    touched,
    error,
    valid,
    set(v: T) {
      value.set(v)
      touched.set(true)
    },
    reset() {
      value.set(initial)
      touched.set(false)
    },
  }
}

// Usage
const email = createFormField('', (v) => (v.includes('@') ? null : 'Invalid email'))
const password = createFormField('', (v) =>
  v.length >= 8 ? null : 'Must be at least 8 characters',
)

const formValid = computed(() => email.valid() && password.valid())

effect(() => {
  console.log('Form valid:', formValid())
  console.log('Email error:', email.error())
  console.log('Password error:', password.error())
})

Reactive API Layer

import { signal, createResource, batch } from '@pyreon/reactivity'

function createApi<T>(baseUrl: string) {
  const items = signal<T[]>([])
  const loading = signal(false)
  const error = signal<string | null>(null)

  async function fetchAll() {
    loading.set(true)
    error.set(null)
    try {
      const res = await fetch(baseUrl)
      const data = await res.json()
      batch(() => {
        items.set(data)
        loading.set(false)
      })
    } catch (e) {
      batch(() => {
        error.set(String(e))
        loading.set(false)
      })
    }
  }

  async function create(item: Partial<T>) {
    const res = await fetch(baseUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(item),
    })
    const created = await res.json()
    items.update((list) => [...list, created])
    return created
  }

  async function remove(id: string) {
    await fetch(`${baseUrl}/${id}`, { method: 'DELETE' })
    items.update((list) => list.filter((item: any) => item.id !== id))
  }

  return { items, loading, error, fetchAll, create, remove }
}

// Usage
const todosApi = createApi<Todo>('/api/todos')
todosApi.fetchAll()

effect(() => {
  if (todosApi.loading()) console.log('Loading todos...')
  else console.log('Todos:', todosApi.items().length)
})

Reactive Computed Chains with Memoization

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

const rawProducts = signal<Product[]>([])
const searchQuery = signal('')
const sortBy = signal<'name' | 'price' | 'rating'>('name')
const sortDirection = signal<'asc' | 'desc'>('asc')

// Each computed only re-evaluates when its direct dependencies change
const filteredProducts = computed(() => {
  const query = searchQuery().toLowerCase()
  if (!query) return rawProducts()
  return rawProducts().filter(
    (p) => p.name.toLowerCase().includes(query) || p.description.toLowerCase().includes(query),
  )
})

const sortedProducts = computed(() => {
  const products = filteredProducts()
  const key = sortBy()
  const dir = sortDirection() === 'asc' ? 1 : -1
  return [...products].sort((a, b) => {
    if (a[key] < b[key]) return -1 * dir
    if (a[key] > b[key]) return 1 * dir
    return 0
  })
})

const paginatedProducts = computed(() => {
  const page = currentPage()
  const perPage = 20
  return sortedProducts().slice(page * perPage, (page + 1) * perPage)
})

const totalPages = computed(() => Math.ceil(sortedProducts().length / 20))

Undo/Redo with Signals

import { signal } from '@pyreon/reactivity'

function createUndoable<T>(initial: T) {
  const current = signal(initial)
  const history = signal<T[]>([initial])
  const index = signal(0)

  const canUndo = () => index() > 0
  const canRedo = () => index() < history().length - 1

  function set(value: T) {
    const newHistory = history().slice(0, index() + 1)
    newHistory.push(value)
    history.set(newHistory)
    index.set(newHistory.length - 1)
    current.set(value)
  }

  function undo() {
    if (!canUndo()) return
    index.update((i) => i - 1)
    current.set(history()[index()])
  }

  function redo() {
    if (!canRedo()) return
    index.update((i) => i + 1)
    current.set(history()[index()])
  }

  return { current, set, undo, redo, canUndo, canRedo }
}

const editor = createUndoable('')
editor.set('Hello')
editor.set('Hello, World')
editor.undo() // current() === "Hello"
editor.redo() // current() === "Hello, World"

Exports Summary

Core Primitives

functionsignal
signal<T>(value: T, options?: { name?: string }): Signal<T>
Creates a reactive signal — the fundamental unit of reactivity in Pyreon.
functioncomputed
computed<T>(fn: () => T, options?: { equals?: (a: T, b: T) => boolean }): Computed<T>
Creates a lazy derived value that re-evaluates when its dependencies change.
functioneffect
effect(fn: () => void): Effect
Runs a function and re-runs it whenever its reactive dependencies change.
functionwatch
watch<T>(source: () => T, cb: (value: T, prev: T) => (() => void) | void): () => void
Watches a reactive source with old/new values and optional cleanup.

Batching & Scheduling

functionbatch
batch(fn: () => void): void
Batches multiple signal updates into a single reactive flush.
functionnextTick
nextTick(): Promise<void>
Returns a promise that resolves after the current microtask flush.

Stores

functioncreateStore
createStore<T extends object>(initial: T): T
Creates a deep reactive proxy store with automatic nested tracking.
functionshallowReactive
shallowReactive<T extends object>(initial: T): T
Vue-3 parity. Creates a SHALLOWLY reactive store: top-level writes notify, nested mutations don't.
functionmarkRaw
markRaw<T extends object>(value: T): T
Vue-3 parity. Mark an object so createStore / shallowReactive return it unwrapped (class instances, third-party objects, DOM nodes).
functionisStore
isStore(value: unknown): boolean
Type guard — returns true if the value is a Pyreon reactive store proxy.
functionreconcile
reconcile<T>(store: T, data: T): void
Surgically diffs new data into an existing store, minimizing reactive updates.

Resources

functioncreateResource
createResource<T>(fetcher: () => Promise<T>): Resource<T>
Creates an async reactive resource with loading/error/data signals.

Scopes & Utilities

functioneffectScope
effectScope(): EffectScope
Creates a new EffectScope for grouping and disposing effects together.
functionrunUntracked
runUntracked<T>(fn: () => T): T
Runs a function without tracking any reactive dependencies.
functionuntrack
untrack<T>(fn: () => T): T
Alias for runUntracked. Shorter name, identical behavior.
functioncreateSelector
createSelector<T>(source: () => T): (key: T) => boolean
Creates an O(1) equality selector for efficient list item matching.

Debug Utilities

functionsetErrorHandler
setErrorHandler(handler: (error: unknown) => void): void
Sets the global error handler for uncaught errors in effects.
functiononSignalUpdate
onSignalUpdate(listener: (signal: Signal<unknown>) => void): () => void
Registers a listener called on every signal write. Useful for devtools.
functionwhy
why(): void
Traces the next signal update to console, showing which signal triggered which effects.
functioninspectSignal
inspectSignal(signal: Signal<unknown>): void
Prints a signal's current state, subscribers, and debug info to console.

Signal Facades with wrapSignal

When you need a signal whose write performs a side effect—persisting to localStorage, emitting a patch to a server, validating input—use wrapSignal() to build a facade over a base signal.

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

const base = signal(0)
const wrapped = wrapSignal(base, {
  set: (v) => {
    base.set(v)                    // update the base
    localStorage.setItem('key', JSON.stringify(v))  // persist
  },
})

wrapped.set(5)
console.log(wrapped())  // 5 (reads from base)
console.log(localStorage.getItem('key'))  // "5"

Why not hand-roll a facade?

The canonical foot-gun is building a facade by hand—wrapping the signal in an object shape:

// ❌ BROKEN — hand-rolled facade
const facade = {
  ...base,
  set: (v) => { base.set(v); persist() },
}

This LOOKS like a signal but silently breaks compiled templates. The Pyreon compiler emits _bindText(facade, textNode) for text bindings, which reads facade._v directly to skip the function call. A hand-rolled facade exposes .direct() from base but forgets _v—so the fast path sees undefined and renders the binding empty.

wrapSignal() guards against this by construction: it forwards BOTH _v (via Object.defineProperty getter) and .direct() together, so the mistake is structurally impossible.

// ✅ CORRECT — wrapSignal handles it
const wrapped = wrapSignal(base, {
  set: (v) => { base.set(v); persist() },
})
// The wrapped facade forwards _v, .direct(), .peek(), .subscribe()...
// compiled bindings work, hand-rolled ones don't.

Per-consumer identity

wrapSignal() returns a distinct callable each time, so if you need independent subscription/refcount semantics over a SHARED base, you get it for free:

const base = signal(0)
const a = wrapSignal(base, { set: (v) => base.set(v) })
const b = wrapSignal(base, { set: (v) => base.set(v) })

expect(a).not.toBe(b)  // different identities
expect(a()).toBe(b())  // same value (shared base)

a.set(5)
expect(b()).toBe(5)  // both read the shared base

This is essential for subscription patterns where consumers track .subscribe() handles for independent cleanup.

Custom update behavior

By default, update(fn) is sugar for set(fn(peek())). Override it for custom coalescing or batch semantics:

const writes: number[] = []
const wrapped = wrapSignal(base, {
  set: (v) => {
    writes.push(v)
    base.set(v)
  },
  update: (fn) => {
    const next = fn(base.peek())
    // Custom update: maybe debounce, maybe validate before calling set
    if (next >= 0) {
      wrapped.set(next)  // routes through custom set
    }
  },
})

wrapped.update(n => n + 1)  // custom update runs
expect(writes).toContain(1)

Real-world: persistence + reactivity

A common pattern is wrapping a persisted signal:

const baseCount = signal(JSON.parse(localStorage.getItem('count') ?? '0'))

const count = wrapSignal(baseCount, {
  set: (v) => {
    baseCount.set(v)
    localStorage.setItem('count', JSON.stringify(v))
  },
})

effect(() => {
  console.log('Count is now:', count())
})

count.set(5)  // persists + triggers effect

Delegation chain

All reads delegate to base:

  • wrapped()base()

  • wrapped.peek()base.peek()

  • wrapped.subscribe(listener)base.subscribe(listener)

  • wrapped.direct(updater)base.direct(updater)

  • wrapped.debug()base.debug()

  • wrapped.label (get/set) ↔ base.label

Writes are intercepted:

  • wrapped.set(v) → custom set handler

  • wrapped.update(fn) → custom update handler (or default set(fn(peek())))

The internal _v field is forwarded live via property getter, so the compiler's fast path always sees the current value.

@pyreon/reactivity