signals

Search:
Group by:

Signals - Durable Reactive Effects for Nim

This module implements signals and a lightweight runtime that keeps values, collections, and whole object graphs in sync automatically. The key ideas and how they fit together are outlined below. Each concept links to the concrete API symbol that realises it.

Signals supports complex object hierarchies effortlessly:

type
  Vec2 = object
    x*, y*: float
  
  SpeciesX* = enum spGoblinX, spOgreX, spDragonX
  ElementX* = enum elNoneX, elFireX, elIceX, elLightningX
  
  ResistancesX* = Table[ElementX, float]
  InventoryX* = Table[string, int]
  
  CoreStatsX* = object
    hp*, mp*: int = 100
    pos*: Vec2 = Vec2(x: 0, y: 0)
    resist*: ResistancesX
  
  MonsterX* = object
    id*: int
    name*: string = "unnamed-X"
    tags*: seq[string] = @[]
    inv*: InventoryX
    stats*: CoreStatsX
    
    case kind*: SpeciesX
    of spGoblinX:
      speed*: float
    of spOgreX:
      clubDmg*: int
    of spDragonX:
      element*: ElementX
      breath*: array[3, float]

reactive(Vec2)
reactive(CoreStatsX)
reactive(MonsterX)

And handles signal propagation from easy:

let ctx = newReactiveCtx()

var hp    = ctx.signal 100
var maxHp = ctx.signal 150

let pct = ctx.computed () => hp.val / maxHp.val

ctx.effect proc =
  echo "health bar width ", pct.val * 100, "%"

hp -= 10
hp.set 0

# output:
# health bar width 66.66666666666666%
# health bar width 60.0%
# health bar width 0.0%

to hard:

suite "MonsterX wrapper":
  
  proc baseStats(hp = 100, mp = 0): CoreStatsX =
    CoreStatsX(hp: hp, mp: mp,
              pos: Vec2(x: 0, y: 0),
              resist: initTable[ElementX,float]())
  
  proc newGoblin(id = 1): MonsterX =
    MonsterX(id: id,
            name: "Gob" & $id,
            tags: @["evil"],
            inv: initTable[string,int](),
            stats: baseStats(),
            kind: spGoblinX,
            speed: 2.0)
  
  proc newDragon(id = 1): MonsterX =
    MonsterX(id: id,
            name: "Drg" & $id,
            tags: @["boss"],
            inv: initTable[string,int](),
            stats: baseStats(300),
            kind: spDragonX,
            element: elFireX,
            breath: [0.3, 0.3, 0.4])
  
  test "Shared primitive propagates":
    let C = newReactiveCtx()
    var m = C.toReactive(newGoblin())
    var hits = 0
    C.effect proc =
      discard m.stats.hp.val
      inc hits
    
    m.stats.hp += 25
    check m.stats.hp.val == 125
    check hits == 2           # mount + after write
  
  test "Table alias (ResistancesX) is reactive":
    let C = newReactiveCtx()
    var m = C.toReactive(newGoblin())
    var log: seq[float]
    
    C.effect proc =
      discard m.stats.resist[elIceX].val
      log.add m.stats.resist[elIceX].val
    
    m.stats.resist[elIceX] = 0.5       # structural put → new signal
    check log == @[0.0, 0.5]
  
  test "Seq alias (tags) is reactive and undo-able":
    let C = newReactiveCtx()
    var m = C.toReactive(newGoblin())
    m.tags.push "fast"
    undo C
    check m.tags.len == 1              # only original "evil"
  
  test "Inventory (Table alias) structural edits":
    let C = newReactiveCtx()
    var m = C.toReactive(newGoblin())
    var revs: seq[int]
    
    C.effect proc =
      discard rev(m.inv)               # structural rev counter
      revs.add rev(m.inv)
    
    m.inv.put("gold", 100)
    m.inv.put("potion", 2)
    check m.inv.len == 2
    check revs.len == 3                # mount + 2 puts
  
  test "Variant transition keeps shared deps":
    let C = newReactiveCtx()
    var m = C.toReactive(newGoblin())
    var hpHits = 0
    C.effect proc =
      discard m.stats.hp.val           # shared across variants
      inc hpHits
    
    check m.stats.hp.val == 100
    
    # transition Goblin → Dragon
    let snapTags = m.tags.toPlain()
    check snapTags == @["evil"]
    
    let nd = newDragon()
    check nd.stats.hp == 300
    
    m.set nd
    
    check m.stats.hp.val == 300
    
    m.stats.hp -= 1
    
    check m.stats.hp.val == 299
    
    check m.kind == spDragonX
    check m.element == elFireX
    check hpHits == 3                  # mount + once after transition
    
    let newTags = m.tags.toPlain()
    check newTags == @["boss"]
  
  test "Undo across variant transition":
    let C = newReactiveCtx()
    var m = C.toReactive(newGoblin())
    m.set newDragon()
    undo C
    check m.kind == spGoblinX
    check m.speed.val == 2.0
  
  test "toPlain snapshot after deep edits":
    let C = newReactiveCtx()
    var m = C.toReactive(newGoblin())
    let snap = m.toPlain()
    m.stats.pos.x.set 10
    m.inv.put("dagger", 1)
    check snap.stats.pos.x == 0        # untouched snapshot
    check "dagger" notin snap.inv

Signals

  • A Signal stores one value of type T and remembers every observer that reads it through val (or wrapper access).
  • Writing with set records an undo entry, notifies observers via the active scheduler, and marks the context dirty for autosave.

Derived values

  • computed builds a new signal from other signals and caches the result until any dependency changes.
  • memo offers the same caching but exposes a plain callable instead of a signal.
  • effect and its cleanup variant run side effects when dependencies change, while effectOnce runs exactly one time.

Batching and atomic updates

  • transaction temporarily increments ctx.batching. Every signal write inside the block queues notifications and coalesces history so observers and autosave fire only once after the outermost transaction ends.

Undo and redo

  • Each effective change pushes a HistoryEntry onto the undo stack.
  • undo pops entries, calls their undo callback, and moves them to the redo stack; redo moves them back.
  • snapshot captures the current depth and travel jumps to any snapshot by replaying the needed undo or redo steps.
  • undoDepth and redoDepth report the current stack sizes.

Persistence

Containers

  • ReactiveSeq wraps a seq. Index access returns element signals; structural mutators update len and rev.
  • ReactiveTable wraps a Table. Key access returns value signals and structural edits update len and rev for the table.

Object wrappers

  • The reactive macro generates a parallel XReactive type whose fields are signals or nested reactive wrappers.
  • toReactive converts a plain object or collection into its wrapper; toPlain returns an immutable deep copy.

Schedulers

Context lifecycle

  • All reactive entities belong to a ReactiveCtx.
  • Create one with newReactiveCtx, dispose it with dispose.
  • After disposal signal writes still change the stored value but no reactions, history entries, or autosave snapshots are produced

Types

AddCleanup = proc (c: Cleanup)

Type alias for the onCleanup callback that can be passed to effect.

A cleanup is a proc () that will be executed right before the enclosing effect runs again or when its ReactiveCtx is disposed. Use it to tear down timers, event handlers, or other external resources that the effect created.

Workflow

ctx.effect proc (onCleanup: AddCleanup) =
  let timer = setTimer(1000, step)    # acquire resource
  onCleanup(() => clearTimer(timer)   # release it next run / dispose
)

  • The effect runs once, establishing its dependencies.
  • onCleanup registers any number of callbacks.
  • Before the effect re-executes, each callback is invoked in the order they were added.

When to use

  • Setting up and tearing down external listeners (DOM, sockets, timers).
  • Releasing memory or file handles that live only for the lifetime of the effect.

Pitfalls

  • Do not call onCleanup conditionally inside loops; register once per resource or you may leak callbacks.
  • A cleanup runs even if the signal values did not actually change, as long as the effect re-evaluates; design idempotent cleanup code.
  • Calling dispose(ctx) triggers cleanups exactly once; further writes to the same signals will no longer schedule cleanups.
Cleanup = proc ()

Zero-argument callback that releases a resource previously created by an effect. A Cleanup is registered through the AddCleanup callback passed to effect (see that doc comment for usage details) and is executed right before the effect runs again or when the owning ReactiveCtx is disposed.

Typical tasks performed in a Cleanup:

  • clearing timers
  • unbinding event listeners
  • closing files or sockets

Keep the body idempotent and side-effect free beyond the intended teardown; it may be called even when signal values did not change if the effect re-evaluates for other reasons.

HistoryEntry = tuple[undo, redo: proc ()]

Internal record used by the undo/redo system.

A HistoryEntry stores two zero-argument procs:

  • undo – restores the previous state.
  • redo – reapplies the change that undo reverted.

The reactive engine pushes one HistoryEntry onto the context’s undo stack for every effective state change that occurs outside of an explicit call to undo, redo, or dispose.

How it fits in

  • undo(ctx) pops an entry, calls undo, and pushes the same entry onto the redo stack.
  • redo(ctx) pops from the redo stack, calls redo, and pushes the entry back onto the undo stack.

When to interact with it

End-users do not create HistoryEntry values directly. They appear only if you are extending the library with new reactive container types and need to record structural or batched changes. In that case create an entry and push it just like the built-in seq and table wrappers do.

Pitfalls

  • The procs must not capture values by reference that can go out of scope before the entry is executed.
  • Do not call signal setters inside undo or redo that would push new history entries; set the raw field values instead or wrap the call inside ctx.isUndoing / ctx.isRedoing.
JsonNodeReactive = ref object
  ctx*: ReactiveCtx
  kind*: Signal[JsonNodeKind]
  fields*: ReactiveTable[string, JsonNodeReactive] ## for JObject
  items*: ReactiveSeq[JsonNodeReactive] ## for JArray
  strVal*: Signal[string]
  intVal*: Signal[int]
  floatVal*: Signal[float]
  boolVal*: Signal[bool]
One wrapper node per JsonNode in the original tree.
ReactiveCtx = ref object

A runtime container that owns and orchestrates every reactive entity in one isolated graph.

Purpose

Each ReactiveCtx keeps its own signals, effects, scheduler, undo/redo history, and autosave target. Creating more than one context lets you run independent reactive sub-systems side by side.

Key responsibilities

  • Stores all signals created with signal(ctx, ...) and wrappers built with ctx.toReactive(...).
  • Queues and flushes reactions according to the active scheduler (immediate, queued, or frame).
  • Batches changes while inside transaction(ctx): ....
  • Records HistoryEntry objects so that undo and redo can travel through time.
  • Handles autosave by calling saveInto once per flush when dirty.
  • Shuts everything down and runs pending cleanups when dispose(ctx) is called.

Typical usage

let ctx = newReactiveCtx()
var hp  = signal(ctx, 100)
effect(ctx, () => echo hp.val)
hp -= 10            # effect prints 90

Most programs keep one long-lived context. Tests, modal dialogs, or background jobs often create short-lived contexts so they can dispose them cleanly.

When to create multiple contexts

  • You need separate undo/redo stacks.
  • Different parts of the app should flush on different schedulers.
  • Large data sets need to be unloaded and all reactive memory freed at once via dispose.

Pitfalls and rules

  • Never move a signal between contexts; create it in the context that will own it.
  • After dispose(ctx) reactions stop, but raw Signal values can still mutate silently; avoid further writes.
  • Make sure every transaction block leaves ctx.batching back at 0 even on exceptions.
  • Public helpers (signal, computed, effect, ...) expect a valid, live context; passing a disposed one is a logic error.
ReactiveSeq[T] = ref object

A signal-aware wrapper around a Nim seq[T]. Every element is stored in its own Signal[T], and structural edits (push, insert, remove, clear) are tracked so effects can depend on the length or on the existence of elements.

Purpose

Use ReactiveSeq when you need a growable list whose structure and element values both drive reactive updates. Structural changes push history items, participate in transactions, and trigger autosave just like normal signal writes.

Core behavior

  • Index access rs[i] returns the element signal; read or write it with .val or set.
  • len(rs) and rev(rs) are signals that change when the length or structure changes.
  • Structural mutators: push, pop, insert, removeIdx, clear.
  • Value writes on an element signal do not change len but do push their own undo record.
  • Inside a transaction multiple structural edits coalesce into one HistoryEntry.

Typical usage

var rs = ctx.toReactive(@["a", "b"])   # wrap an existing seq

effect(ctx, () => echo "count:", len(rs))

rs.push "c"        # effect prints count:3
rs[0].set "A"      # structural len unchanged
undo(ctx)          # removes "c"

When to choose ReactiveSeq

  • Lists in UI state (inventory items, chat messages, menu entries).
  • Any ordered collection where individual items update independently.
  • You need fine-grained undo/redo that distinguishes structural vs value edits.

Pitfalls and rules

  • Keep references to element signals (let s = rs[0]) only while the element exists; deleting the index orphanes the signal.
  • Writing rs[i] = v when i is out of range is a logic error.
  • Large loops that write many elements individually should be wrapped in transaction(ctx) to avoid hundreds of history entries.
  • iterator items(rs) yields plain values, so no dependency is tracked; use explicit index reads if you need reactivity inside loops.
ReactiveTable[K; V] = ref object

Reactive wrapper around a Table[K, V]. Each key maps to its own Signal[V], and structural edits are tracked so effects can depend on the entry count or on changes to the key set.

Purpose

Use ReactiveTable when you need a dictionary whose keys may appear or disappear at runtime and whose values must update dependent code automatically. Both structural operations and per value writes integrate with undo, redo, transactions, and autosave.

Core behavior

  • rt[key] returns the value signal for that key, creating a new signal with default(V) if the key is missing.
  • len(rt) and rev(rt) are signals that change when the size or the structure changes.
  • Structural mutators: put, delKey, clear.
  • Writing through a value signal (rt["hp"].set 10) leaves len unchanged but records its own history entry.
  • Inside transaction(ctx) multiple structural edits collapse into one undo record.

Typical usage

var stats = ctx.toReactive(initTable[string,int]())

effect(ctx, () => echo "entries:", len(stats))

stats.put("hp", 10)     # effect prints entries:1
stats["hp"] += 5        # structural length unchanged
undo(ctx)               # removes key "hp"

When to choose ReactiveTable

  • Keyed collections in UI or game state, like entity maps or settings.
  • You need both value level and structural level undo and redo.
  • Autosave should persist a whole dictionary without manual loops.

Pitfalls and rules

  • Keep references to value signals only while the key exists; deleting the key leaves the old signal orphaned.
  • Accessing rt[key] for a missing key creates it; use hasKey on a plain table if you only want to test for existence.
  • Large batches of put or delKey should be wrapped in transaction(ctx) to avoid many history entries.
  • Remember to read len(rt) or rev(rt) inside an effect if that effect should rerun on structural changes.
Signal[T] = ref object

Reactive value container that stores one T and notifies its subscribers when the value changes.

Purpose

A Signal is the basic building block of the library. All primitive fields wrapped by reactive(MyType) become signals. Effects and computed signals read them to establish dependencies, and any call to set triggers the update chain.

Core behavior

  • Read with .val or the val(signal) template.
  • Write with set, +=, -=, or *=.
  • Equality helpers let you compare a signal to a plain value in assertions.
  • If the new value equals the current one no history entry is recorded and no reactions fire.
  • Writes inside undo, redo, or dispose bypass history push but still notify dependants.

Typical usage

var hp = signal(ctx, 100)
effect(ctx, () => echo "HP:", hp.val)
hp -= 10            # effect prints HP: 90

When to declare a Signal yourself

  • Stand-alone values not part of a reactive object wrapper.
  • Global settings that require independent undo history.
  • Quick experiments or tests where a full wrapper type is overkill.

Pitfalls and rules

  • Never create a signal without passing the owning ReactiveCtx.
  • Avoid writing the same value repeatedly; it adds no benefit but still triggers change detection.
  • Signals are reference types; copying a Signal[T] variable copies the reference, not the value.
  • After dispose(ctx) writing a signal mutates the stored value but no reactions will run.

Procs

proc `&`[T](a: ReactiveSeq[T]; b: seq[T]): ReactiveSeq[T]
proc `&`[T](a: ReactiveSeq[T]; elem: T): ReactiveSeq[T]
proc `&`[T](a: seq[T]; b: ReactiveSeq[T]): ReactiveSeq[T]
proc `&`[T](elem: T; b: ReactiveSeq[T]): ReactiveSeq[T]
proc `&=`[T](rs: ReactiveSeq[T]; elem: T)
proc `&=`[T](rs: ReactiveSeq[T]; rhs: seq[T])
proc `*`[T](a, b: Signal[Complex[T]]): Signal[Complex[T]]
proc `*`[T](a: Complex[T]; b: Signal[Complex[T]]): Signal[Complex[T]]
proc `*`[T](a: Signal[Complex[T]]; b: Complex[T]): Signal[Complex[T]]
proc `*=`[T](s: Signal[Complex[T]]; rhs: Complex[T])
proc `+`[T](a, b: Signal[Complex[T]]): Signal[Complex[T]]
proc `+`[T](a: Complex[T]; b: Signal[Complex[T]]): Signal[Complex[T]]
proc `+`[T](a: Signal[Complex[T]]; b: Complex[T]): Signal[Complex[T]]
proc `+=`[T](s: Signal[Complex[T]]; rhs: Complex[T])
proc `-`[T](a, b: Signal[Complex[T]]): Signal[Complex[T]]
proc `-`[T](a: Complex[T]; b: Signal[Complex[T]]): Signal[Complex[T]]
proc `-`[T](a: Signal[Complex[T]]; b: Complex[T]): Signal[Complex[T]]
proc `-`[T](s: Signal[Complex[T]]): Signal[Complex[T]]
Unary minus
proc `-=`[T](s: Signal[Complex[T]]; rhs: Complex[T])
proc `/`[T](a, b: Signal[Complex[T]]): Signal[Complex[T]]
proc `/`[T](a: Complex[T]; b: Signal[Complex[T]]): Signal[Complex[T]]
proc `/`[T](a: Signal[Complex[T]]; b: Complex[T]): Signal[Complex[T]]
proc `/=`[T](s: Signal[Complex[T]]; rhs: Complex[T])
proc `==`[K, V](a: ReactiveTable[K, V]; b: Table[K, V]): bool
proc `==`[K, V](a: Table[K, V]; b: ReactiveTable[K, V]): bool
proc `==`[T](a: ReactiveSeq[T]; b: seq[T]): bool
proc `==`[T](a: seq[Signal[T]]; b: seq[T]): bool
proc `==`[T](a: seq[T]; b: ReactiveSeq[T]): bool
proc `==`[T](a: seq[T]; b: seq[Signal[T]]): bool
proc `==`[T](a: seq[T]; b: Signal[seq[T]]): bool
proc `==`[T](a: Signal[seq[T]]; b: seq[T]): bool
proc `==`[T](a: Signal[T]; b: T): bool
proc `==`[T](a: T; b: Signal[T]): bool
proc `[]`[K, V](rt: ReactiveTable[K, V]; key: K): Signal[V]
proc `[]=`[K, V](rt: ReactiveTable[K, V]; key: K; val: V)
proc `[]=`[T](rs: ReactiveSeq[T]; idx: int; v: T)
proc abs[T](s: Signal[Complex[T]]): Signal[T]

result = abs(s.val) (reactive)

proc abs2[T](s: Signal[Complex[T]]): Signal[T]

result = abs2(s.val) (squared magnitude)

proc almostEqual[T](a, b: Signal[Complex[T]]; ulp: Natural = 4): Signal[bool]
proc `and`[T: SomeInteger](a, b: Signal[T]): Signal[T]
proc `and`[T: SomeInteger](a: Signal[T]; b: T): Signal[T]
proc `and`[T: SomeInteger](a: T; b: Signal[T]): Signal[T]
proc arccos[T](s`gensym124: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc arccosh[T](s`gensym136: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc arccot[T](s`gensym128: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc arccoth[T](s`gensym140: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc arccsc[T](s`gensym132: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc arccsch[T](s`gensym144: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc arcsec[T](s`gensym130: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc arcsech[T](s`gensym142: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc arcsin[T](s`gensym122: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc arcsinh[T](s`gensym134: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc arctan[T](s`gensym126: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc arctanh[T](s`gensym138: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc binarySearch[T](rs: ReactiveSeq[T]; key: T): int
proc bitslice[T: SomeInteger](s: Signal[T]; slice: Slice[int])
Condenses bits from slice keeping the LSB at `slice.a` (matches the expectation of your test-suite).
proc bitsliced[T: SomeInteger](s: Signal[T]; slice: Slice[int]): Signal[T]
Pure computed view returning the sliced value without modifying s.
proc clear[K, V](rt: ReactiveTable[K, V])

Removes all key-value pairs in one step.

Effect on reactivity

  • Pushes a single structural history entry (or joins the active transaction).
  • Resets len(rt) to 0 and bumps rev(rt).
  • Triggers any effects that depend on length or structure.
proc clear[T](rs: ReactiveSeq[T])
Removes all elements from the reactive sequence.
  • Structural edit: sets length to 0 and increments rev(rs).
  • Pushes one history entry (or merges into an active transaction).
  • Effects that depend on length or structure rerun after the flush.

Raises no error when the sequence is already empty.

proc clearBit[T: SomeInteger](s: Signal[T]; bit: BitsRange[T])
proc clearMask[T: SomeInteger](s: Signal[T]; mask: T)
proc clearMask[T: SomeInteger](s: Signal[T]; slice: Slice[int])
proc clearUndo(ctx: ReactiveCtx) {....raises: [], tags: [], forbids: [].}

Purpose

Erases both the undo and redo stacks of the given context without altering current state.

When to call

  • Before loading a fresh document or scene where old history should not apply.
  • In tests that want to assert depth from a clean slate.

Pitfalls

  • Users can no longer undo the edits made earlier in the session.
  • Does not stop reactions; only history is cleared.
proc computed[T](ctx: ReactiveCtx; getter: proc (): T; setter: proc (v: T) = nil): Signal[
    T]

Creates a derived signal whose value is calculated by getter. The result is cached and recomputed only when any signal read inside getter has changed. If setter is supplied the computed signal becomes writable; calling set or pct.set(x) forwards the write to setter.

How it fits in

  • Use computed for values that can be expressed entirely in terms of other signals.
  • Effects depending on the computed signal see a single dependency instead of all the sources used inside getter.
  • Writable computed signals let you expose a convenient virtual field (for example percent health) that writes back to one or more base signals.

Example

let pct = computed(ctx,
  getter = () => hp.val.float / maxHp.val.float,
  setter = (p: float) => hp.set int(p * maxHp.val.float))

echo pct.val   # read
pct.set 0.8    # write through setter

Pitfalls

  • getter runs immediately when computed is created; avoid heavy work or side effects inside it.
  • Do not call set on the computed signal from inside its own getter; that would cause infinite recursion.
  • If setter writes the same value back into the sources the engine detects no change and pushes no history entry.
proc conjugate[T](s`gensym113: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc cos[T](s`gensym123: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc cosh[T](s`gensym135: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc cot[T](s`gensym127: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc coth[T](s`gensym139: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc csc[T](s`gensym131: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc csch[T](s`gensym143: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc delKey[K, V](rt: ReactiveTable[K, V]; key: K)

Removes key from the reactive table if it exists.

Reactive effects

  • Structural operation; bumps rev(rt) and updates len(rt).
  • Pushes a history entry (or merges into an open transaction).
  • Effects that depend on length or on rev(rt) rerun.

Keys that are not present are ignored and no history entry is added.

proc disableAutosave(ctx: ReactiveCtx) {....raises: [], tags: [], forbids: [].}

Turns off automatic persistence for the context. No further snapshots are written to the JSON node that was previously passed to enableAutosave.

Effect

  • Clears the autosave flag and the stored node reference.
  • Does not perform a final save; call flushQueued(ctx) first if you need the latest state persisted.

Pitfalls

  • After disabling, edits to store signals will not reach the old JSON node unless autosave is enabled again or saveState is called manually.
proc dispose(ctx: ReactiveCtx) {....raises: [KeyError, Exception],
                                 tags: [RootEffect], forbids: [].}

Stops all reactive activity owned by ctx and frees its resources.

What happens

  • If autosave is active a final snapshot is written before shutdown.
  • Pending reaction queues and history stacks are cleared.
  • Scheduler is replaced with a no-op and currentObserver is set to nil, so no further reactions run.
  • ctx.isDisposed becomes true; helper procs treat the context as dead.

Typical usage

let ctx = newReactiveCtx()
...
dispose ctx      # when the data set or UI panel is closed

Pitfalls

  • Writing to a signal after disposal mutates its raw value but triggers no reactions or autosave.
  • A disposed context cannot be revived. Create a new context instead.
  • Transactions open at the moment of disposal are silently cancelled.
proc dumpDeps(s: Signal): seq[string]
Return a seq of hex strings, one per subscriber callback. Helpful for quick "who depends on this signal?" debugging.
proc effect(ctx: ReactiveCtx; body: proc ()) {....raises: [Exception],
    tags: [RootEffect], forbids: [].}

Registers body as a reactive side effect. The procedure runs once immediately, collects every signal it reads, and is scheduled to run again whenever any of those signals changes.

Purpose

Effects are the bridge from reactive data to the outside world. Use them for printing, updating the UI, sending network messages, or any work whose result is not itself a signal.

How it works

  • During the first call ctx.currentObserver is set to the callback so every signal read registers the dependency automatically.
  • Later writes to any of those signals schedule the effect.
  • If the active scheduler is queued or frame based, the run happens on the next flushQueued(ctx) or frame tick.
  • Dependencies are re-collected on every run; if body stops reading a signal it will be unsubscribed.

Example

effect(ctx, proc () =
  echo "HP:", hp.val, "/", maxHp.val
)

Pitfalls

  • Do not mutate signals inside the same effect that reads them unless you are certain it will not cause infinite loops.
  • An effect runs even if the computed result is identical; add manual checks if you need extra deduplication.
  • Heavy work should be batched or throttled to avoid frame drops under rapid updates.
proc effect(ctx: ReactiveCtx; body: proc (onCleanup: AddCleanup)) {.
    ...raises: [Exception], tags: [RootEffect], forbids: [].}

Registers a reactive side effect that can schedule cleanup callbacks. body receives an onCleanup function; every Cleanup registered with it runs right before the effect re executes or when the context is disposed.

Purpose

Same as the simple effect, plus deterministic setup and teardown of resources such as timers, event listeners, or file handles.

Usage pattern

effect(ctx, proc (onCleanup: AddCleanup) =
  let id = setTimer(1000, tick)
  onCleanup(() => clearTimer(id))
)

  • First run: acquire resources, register cleanups, establish dependencies.
  • On every re run: all cleanups fire, then the body executes again.
  • On dispose(ctx): any remaining cleanups run once.

Pitfalls

  • Register each resource exactly once; adding inside loops can leak callbacks.
  • Cleanups run even when signal values did not change if the effect still re evaluates. Keep them lightweight and idempotent.
  • Avoid calling onCleanup conditionally based on signal values that may differ between runs; otherwise the number of callbacks can grow unbounded.
proc effectOnce(ctx: ReactiveCtx; body: proc ()) {....raises: [Exception],
    tags: [RootEffect], forbids: [].}

Executes body immediately and never schedules it again, even if the procedure reads signals that later change. Dependency tracking is disabled during the call.

Purpose

Use effectOnce for one time side effects such as logging, initialising third party libraries, or writing a first snapshot after startup.

Example

effectOnce(ctx, proc () =
  echo "Initial HP:", hp.val
)

Pitfalls

  • Because no dependencies are collected, later updates do not trigger the procedure. Choose a normal effect if you need ongoing reactivity.
  • Cleanups cannot be registered; allocate resources only if they live for the full lifetime of the context.
proc enableAutosave(ctx: ReactiveCtx; target: var JsonNode) {.
    ...raises: [KeyError, Exception], tags: [RootEffect], forbids: [].}

Activates automatic persistence for all store-registered signals in ctx. The current values are written into target immediately and once per flush whenever any store signal changes.

How it works

  • If target is nil a new empty JSON object is created.
  • Every call to flushQueued(ctx) (or an automatic flush by the scheduler) serialises the latest store snapshot into the same JSON node. Existing keys are overwritten, other keys in target remain.
  • On dispose(ctx) a final snapshot is written.

Typical usage

var doc: JsonNode
enableAutosave(ctx, doc)      # initial snapshot
gold += 10                    # mutate a store signal
flushQueued ctx               # doc updated

Pitfalls

  • Keep target alive as long as autosave is enabled; if you reassign the variable the library keeps the old reference.
  • Call disableAutosave(ctx) before switching to a new node to avoid unintentionally writing into both.
  • Immediate scheduler writes right away; queued or frame schedulers require an explicit flush or a frame tick.
proc exp[T](s`gensym117: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc fill[T](rs: ReactiveSeq[T]; value: T)
Overwrite every element with value, recording ONE undo entry.
proc flipBit[T: SomeInteger](s: Signal[T]; bit: BitsRange[T])
proc flipMask[T: SomeInteger](s: Signal[T]; mask: T)
proc flipMask[T: SomeInteger](s: Signal[T]; slice: Slice[int])
proc flippedBit[T: SomeInteger](s: Signal[T]; bit: BitsRange[T]): Signal[T]
Reactive wrapper: always equals s.val with bit toggled.
proc flushQueued(ctx: ReactiveCtx) {....raises: [Exception, KeyError],
                                     tags: [RootEffect], forbids: [].}

Runs all reactions currently waiting in ctx.queue and then, if autosave is enabled and the context is marked dirty, writes one snapshot to the autosave JSON node.

Purpose

Use flushQueued when working with the queued or frame schedulers if you need to force updates immediately, or in automated tests where you want deterministic timing without depending on the scheduler loop.

What it does

  • Copies the current queue, clears it, and executes each reaction once.
  • Clears queueHashes so the same reaction can be queued again later.
  • If autosave is active and any write happened since the last snapshot, serialises the store signals into the target JSON node.

Typical usage

useQueuedScheduler(ctx)
value.set 42       # effect is queued
flushQueued ctx    # run it right now

Pitfalls

  • Do not call inside a transaction; the batch is not finished until the outer transaction flushes.
  • Immediate scheduler users never need this call because reactions run at once.
  • Flushing very often defeats the coalescing benefit of queued schedulers.
proc insert[T](rs: ReactiveSeq[T]; idx: int; x: T)
Inserts x at position idx, shifting later elements to the right. Counts as a structural edit: pushes one history entry (or joins the current transaction), updates len(rs) and rev(rs), and reruns effects that depend on length or structure. idx must be in the range 0 .. rs.len.
proc inv[T](s`gensym114: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc isSorted[T](rs: ReactiveSeq[T]; cmp: (T, T) -> int;
                 order = SortOrder.Ascending): bool
proc isSorted[T](rs: ReactiveSeq[T]; order = SortOrder.Ascending): bool
Dependency-tracked check that never touches .val
proc ln[T](s`gensym118: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc loadState(ctx: ReactiveCtx; src: JsonNode) {....raises: [Exception, KeyError],
    tags: [RootEffect], forbids: [].}

Replaces the values of all store-registered signals in ctx with the contents of src. Keys that are missing in src leave their signals unchanged. Signals not registered with store or registerStore are unaffected.

Typical workflow

var snapshot: JsonNode
saveState(ctx, snapshot)   # later...
loadState(ctx, snapshot)   # restore previous snapshot

Effects on history and reactions

  • Each setter call triggered by loadState pushes its own undo record unless the context is inside isUndoing, isRedoing, or isDisposed.
  • Reactions fire normally after the load; wrap the call in transaction(ctx) if you want one coalesced update.

Pitfalls

  • src must not be nil; pass an empty object to clear all stored keys.
  • Data types are not validated beyond basic conversion; loading a float into an int signal raises a value error.
  • The redo stack is cleared after the first new write; if you load a snapshot and then set a signal manually the forward history is lost.
proc log2[T](s`gensym120: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc log10[T](s`gensym119: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc lowerBound[T, K](rs: ReactiveSeq[T]; key: K; cmp: (T, K) -> int): int
proc lowerBound[T](rs: ReactiveSeq[T]; key: T): int
proc mask[T: SomeInteger](s: Signal[T]; mask: T)
proc mask[T: SomeInteger](s: Signal[T]; slice: Slice[int])
proc memo[T](ctx: ReactiveCtx; selector: () -> T): () -> T

Creates a memoised selector. The returned closure reads its cached value without tracking dependencies, while an internal effect keeps that cache up to date by running selector again only when any of the signals it reads change.

Purpose

Use memo to avoid expensive recalculation or to expose a selector that can be read many times without causing additional reactivity churn.

How it works

  • On creation selector runs once, collects its dependencies, and stores the result.
  • An internal effect reruns selector when any dependency changes and updates the cache.
  • The returned closure just returns the current cache, adding itself as a dependency of callers.

Example

let sum = memo(ctx, () => a.val + b.val)
echo sum()   # fast cached read

Pitfalls

  • Do not call memo inside another effect if the selector performs heavy work; create it once at module or context setup time.
  • Values are compared with !=; if T has complex equality rules make sure that still detects real changes.
  • The memo itself never pushes undo history; only the underlying signal writes do.
proc newReactiveCtx(): ReactiveCtx {....raises: [], tags: [], forbids: [].}

Constructs and returns a fresh ReactiveCtx with the default immediate scheduler and empty history, queues, and store list.

When to call

  • At application startup to create the primary reactive graph.
  • Inside tests or short-lived components to obtain an isolated context that can be disposed without affecting the rest of the program.

The new context is clean: undoDepth, redoDepth, and queue.len are all zero, isDisposed is false, and autosave is disabled.

proc newReactiveNull(ctx: ReactiveCtx): JsonNodeReactive {....raises: [Exception],
    tags: [RootEffect], forbids: [].}
proc nextPermutation[T](rs: ReactiveSeq[T]): bool {.discardable.}
proc `not`[T: SomeInteger](a: Signal[T]): Signal[T]
proc `or`[T: SomeInteger](a, b: Signal[T]): Signal[T]
proc `or`[T: SomeInteger](a: Signal[T]; b: T): Signal[T]
proc `or`[T: SomeInteger](a: T; b: Signal[T]): Signal[T]
proc parityBits[T: SomeInteger](s: Signal[T]): int
proc phase[T](s: Signal[Complex[T]]): Signal[T]

result = phase(s.val) (argument / angle)

proc ping[K, V](rt: ReactiveTable[K, V]) {.inline.}
Wakes dependants of the table-level signals lenS and revS.
proc ping[R](x: R) {.inline.}
Recursively pings every Signal, ReactiveSeq, and ReactiveTable stored anywhere inside x. Works for arbitrarily deep wrapper graphs.
proc ping[R](x: ref R) {.inline.}
Delegates to ping(x[]) when x is not nil.
proc ping[T](rs: ReactiveSeq[T]) {.inline.}
Wakes dependants of the sequence-level signals lenS and revS.
proc ping[T](s: Signal[T]) {.inline.}
Wakes every subscriber of s.
proc polar[T](z: Signal[Complex[T]]): Signal[tuple[r, phi: T]]
Reactive polar-coordinates view of a complex signal. Always equals z.val.polar and re-computes when z changes.
proc pop[T](rs: ReactiveSeq[T]): T
Removes and returns the last element of the reactive sequence. Counts as a structural edit: pushes one undo entry (or joins the current transaction), updates len(rs) and rev(rs), and reruns effects that depend on length or structure. Raises IndexDefect if the sequence is empty.
proc popcount[T: SomeInteger](s: Signal[T]): int
proc pow[T](s: Signal[Complex[T]]; y: Complex[T]): Signal[Complex[T]] {.inline.}
Reactive: rs.pow(otherComplex)
proc pow[T](s: Signal[Complex[T]]; y: T): Signal[Complex[T]] {.inline.}
Reactive: rs.pow(realExponent)
proc prevPermutation[T](rs: ReactiveSeq[T]): bool {.discardable.}
proc push[T](rs: ReactiveSeq[T]; x: T)

Appends x to the end of the reactive sequence.

Reactive effects

  • Structural edit: length grows by 1 and rev(rs) increments.
  • Pushes one history entry or merges into an open transaction.
  • Effects that depend on length or structure rerun.
proc put[K, V](rt: ReactiveTable[K, V]; key: K; val: V)
Adds or replaces key in the reactive table with val. If the key already exists the element signal is updated; otherwise a new element signal is created and a structural history entry is pushed. Effects watching len(rt) or rev(rt) rerun only when the key did not exist before.
proc radius[T](z: Signal[Complex[T]]): Signal[T]

always equals abs(z.val) (== z.val.polar.r)

proc rect[T](r, phi: Signal[T]): Signal[Complex[T]]
reactive polar-to-rect; recomputes when either r or phi changes
proc redo(ctx: ReactiveCtx; steps = 1) {....raises: [Exception],
    tags: [RootEffect], forbids: [].}
Travels forward in the undo history steps times (default 1). Pops entries from the redo stack, executes their redo callbacks, pushes them back onto the undo stack, and schedules reactions. Has no effect when the redo stack is empty.
proc redoDepth(ctx: ReactiveCtx): int {....raises: [], tags: [], forbids: [].}
Returns the current size of the redo stack for ctx. Useful in debugging UIs to enable or disable a 'Redo' button.
proc registerStore[T](ctx: ReactiveCtx; key: string; s: Signal[T])
Adds s to the context's store list under key so that future calls to saveState or active autosave snapshots include its value. Can be called at any time, even after autosave was already enabled.
proc removeIdx[T](rs: ReactiveSeq[T]; idx: int)

Removes the element at idx from a ReactiveSeq.

Effects

  • Structural edit: len(rs) and rev(rs) update.
  • Pushes one history entry (or merges into an open transaction).
  • Triggers effects that depend on length or structure.

Raises IndexDefect if idx is out of range.

proc reverse[T](rs: ReactiveSeq[T])
proc reverse[T](rs: ReactiveSeq[T]; first, last: Natural)
Reverse the elements in the index range first, last inclusively, recording ONE structural history entry.
proc rotateLeft[T](rs: ReactiveSeq[T]; dist: int): int {.discardable.}
proc rotateLeft[T](rs: ReactiveSeq[T]; slice: HSlice[int, int]; dist: int): int {.
    discardable.}
proc rotateLeftBits[T: SomeUnsignedInt](s: Signal[T];
                                        shift: range[0 .. sizeof(T) * 8])
proc rotateLeftBits[T: SomeUnsignedInt](s: Signal[T]; k: int): Signal[T]
Left-rotate k bits (k is wrapped into valid range).
proc rotateRightBits[T: SomeUnsignedInt](s: Signal[T];
    shift: range[0 .. sizeof(T) * 8])
proc rotateRightBits[T: SomeUnsignedInt](s: Signal[T]; k: int): Signal[T]
proc saveState(ctx: ReactiveCtx; dest: var JsonNode) {.
    ...raises: [KeyError, Exception], tags: [RootEffect], forbids: [].}

Writes a snapshot of all store-registered signals in ctx into dest.

Rules

  • Allocates a new JSON object when dest is nil.
  • Overwrites existing keys that match store entries; leaves others untouched.
  • Does not clear history or fire reactions.

Use before closing a document or for manual export; for continuous persistence prefer enableAutosave.

proc sec[T](s`gensym129: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc sech[T](s`gensym141: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc set(self: JsonNodeReactive; src: JsonNode) {.
    ...raises: [Exception, KeyError, Exception], tags: [RootEffect], forbids: [].}
Overwrite the reactive node with the contents of a plain JsonNode.
proc set[K, V](rt: ReactiveTable[K, V]; src: Table[K, V])
Overwrite all keys so that the resulting map equals src. Existing signals are re-used when the key stays; others are created.
proc set[T](rs: ReactiveSeq[T]; vals: seq[T])
Replace the entire sequence in one batched operation.
proc set[T](s: Signal[T]; v: T)

Writes a new value into a Signal.

Purpose

This is the fundamental mutation primitive; every shortcut such as += or element‐signal writes eventually calls set.

  • History Pushes one HistoryEntry that stores both the old and the new value, unless ctx.isUndoing, ctx.isRedoing, or ctx.isDisposed is true.
  • Effects and computed signals All subscribers collected through dependency tracking are notified. The actual reaction runs immediately on the immediate scheduler or is queued for flushQueued(ctx) / the next frame on other schedulers.
  • Transactions Inside transaction(ctx) several calls to set are coalesced: the history entry is created when the outermost transaction finishes and reactions fire once per flush, not per call.
  • Autosave Marks the context dirty so the next flush (manual or scheduled) writes a snapshot when autosave is enabled.
  • Writable computed signals When the target signal has a custom setter (computed(..., setter=...)) the call is forwarded there instead of storing into the signal’s own field, thus supporting virtual two-way bindings.

Pitfalls

  • Writing the same value is ignored; no history entry, no reactions.
  • Do not call set inside the getter of the same computed signal; that creates infinite recursion.
  • After dispose(ctx) the value still mutates but no reactions, history entries, or autosave snapshots will be produced.
proc setBit[T: SomeInteger](s: Signal[T]; bit: BitsRange[T])
proc setMask[T: SomeInteger](s: Signal[T]; mask: T)
proc setMask[T: SomeInteger](s: Signal[T]; slice: Slice[int])
proc sgn[T](s`gensym115: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc signal[T](ctx: ReactiveCtx; v: T): Signal[T]

Allocates a new Signal[T] owned by ctx and initialised to v.

Why you need it

  • Stand-alone reactive values that are not part of a reactive(...) object wrapper.
  • Global settings or counters that several wrappers or effects share.
  • Quick prototypes or tests where defining a wrapper type is overkill.
  • Can be read (sig.val) inside effect, computed, or memo to establish dependencies that rerun those constructs when the signal changes.
  • Can be registered for persistence with store or registerStore; autosave and saveState will then serialise its value.
  • All writes made through set integrate with undo/redo, transactions, autosave, and the active scheduler as described for set.

Typical workflow

let ctx = newReactiveCtx()
var score = signal(ctx, 0)
effect(ctx, () => echo "score:", score.val)
score += 10          # effect prints score: 10

Pitfalls

  • A Signal is a reference object; copying the variable copies only the reference, not the value.
  • Signals must belong to exactly one context; never share the same signal instance across contexts.
  • After dispose(ctx) creating new signals with that context or writing existing ones is a logic error that yields no reactive behaviour.
proc sin[T](s`gensym121: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc sinh[T](s`gensym133: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc snapshot(ctx: ReactiveCtx): int {....raises: [], tags: [], forbids: [].}
Creates a checkpoint of the current undo position and returns its index. Use the returned int later with travel(ctx, index) to jump back or forward to this exact moment in history. Snapshot indices grow monotonically and are valid until the undo and redo stacks are cleared or purged by dispose(ctx).
proc sort[T](rs: ReactiveSeq[T]; cmp: proc (x, y: T): int = cmp[T];
             order = SortOrder.Ascending)
proc sorted[T](rs: ReactiveSeq[T]; cmp: proc (x, y: T): int = cmp[T];
               order = SortOrder.Ascending): seq[T]
proc sorted[T](rs: ReactiveSeq[T]; order: SortOrder): seq[T]
Convenience wrapper that matches std/algorithm.sorted(a, order). Uses the default comparator for T.
proc sqrt[T](s`gensym116: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc store[T](ctx: ReactiveCtx; key: string; init: T): Signal[T]

Creates a new signal initialised to init, registers it under key for persistence, and returns the signal. Future calls to saveState or active autosave snapshots will serialize the signal’s value using the same key in the JSON object.

Typical use:

var gold = store(ctx, "gold", 100)
enableAutosave(ctx, saveNode)
gold += 50          # next flush writes {"gold":150}

proc tan[T](s`gensym125: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc tanh[T](s`gensym137: Signal[Complex[T]]): Signal[Complex[T]] {.inline.}
proc testBit[T: SomeInteger](s: Signal[T]; bit: BitsRange[T]): Signal[bool]
A computed signal that is true when the chosen bit of s is 1. Re-computes automatically whenever s changes.
proc toPlain(jr: JsonNodeReactive): JsonNode {....raises: [], tags: [], forbids: [].}
Builds a fresh immutable JsonNode snapshot of the current state.
proc toPlain[K, V](rt: ReactiveTable[K, V]): Table[K, V]
Returns a plain Table[K, V] that contains the current values of a ReactiveTable. The result is a deep copy; mutating it does not affect the reactive table or its signals.
proc toPlain[T](rs: ReactiveSeq[T]): seq[T]
Converts a ReactiveSeq into a plain seq[T] by copying the current element values. No dependencies are recorded and later changes to the reactive sequence do not affect the returned copy. Use this when you need an immutable snapshot for logging, JSON serialisation, or deep equality tests.
proc toReactive(ctx: ReactiveCtx; n: JsonNode): JsonNodeReactive {.
    ...raises: [Exception, KeyError], tags: [RootEffect], forbids: [].}
Recursively wraps n into a signal-backed tree owned by ctx.
proc toReactive[K, V](ctx: ReactiveCtx; src: Table[K, V]): ReactiveTable[K, V]

Wraps an existing Table[K, V] in ctx, producing a ReactiveTable whose keys keep the same order and whose values are turned into individual signals. The original table is read once; subsequent edits must go through the ReactiveTable API to stay reactive.

Structural and value edits made after wrapping participate in transactions, undo and redo, autosave, and the scheduler just like any table created from scratch.

proc toReactive[T](ctx: ReactiveCtx; src: seq[T]): ReactiveSeq[T]

Wraps an existing seq[T] in ctx, producing a ReactiveSeq where every element becomes its own signal. The original sequence is copied once; later changes must be applied through the ReactiveSeq mutators or by writing the element signals directly.

The resulting wrapper integrates with effects, transactions, undo and redo, autosave, and the active scheduler in the same way as a sequence built via repeated push calls.

proc travel(ctx: ReactiveCtx; index: int) {....raises: [Exception],
    tags: [RootEffect], forbids: [].}
Jumps to the snapshot at index, the value previously returned by snapshot(ctx). If index is lower than the current undo depth the call performs the required number of undo steps. If it is higher the call performs redo steps. Out-of-range indices are ignored. Reactions fire after the move and autosave updates on the next flush.
proc undo(ctx: ReactiveCtx; steps = 1) {....raises: [Exception],
    tags: [RootEffect], forbids: [].}
Reverses the last steps history entries (default 1). For each step the most recent HistoryEntry is popped from the undo stack, its undo callback runs, and the entry is pushed onto the redo stack. Reactions run after the batch and autosave marks the context dirty. Calling undo when the stack is empty does nothing.
proc undoDepth(ctx: ReactiveCtx): int {....raises: [], tags: [], forbids: [].}
Returns the number of HistoryEntry objects currently stored in the undo stack of ctx. A quick way to decide whether an Undo button should be enabled in the UI.
proc upperBound[T, K](rs: ReactiveSeq[T]; key: K; cmp: (T, K) -> int): int
proc upperBound[T](rs: ReactiveSeq[T]; key: T): int
proc useFrameScheduler(ctx: ReactiveCtx; enqueueNextFrm: proc (cb: proc ())) {.
    ...raises: [], tags: [], forbids: [].}

Installs a frame based reaction scheduler.

Purpose

In games and UI toolkits work is often grouped by frames. With the frame scheduler all reactions that occur within the same frame are batched and flushed once at the start of the next frame tick.

How to wire it

var tasks: seq[proc ()] = @[]
useFrameScheduler(ctx, proc(cb: proc ()) = tasks.add cb)

# later in the render loop
for t in tasks: t()
tasks.setLen 0

The supplied enqueueNextFrm must place cb into a queue that the caller empties at the start of the next frame. The library calls enqueueNextFrm exactly once per frame no matter how many reactions were queued.

Effects on the engine

  • Multiple signal writes per frame cause only one flush and one autosave, reducing redundant work.
  • transaction(ctx) blocks still coalesce as usual; the flush happens on the next frame tick.

Pitfalls

  • Forgetting to run the queued callback each frame will stall the reactive graph.
  • Calling flushQueued(ctx) manually is still possible but defeats the batching benefit.
proc useQueuedScheduler(ctx: ReactiveCtx) {....raises: [], tags: [], forbids: [].}

Switches to a simple queued scheduler.

Behavior

  • Every reaction triggered by a signal write is enqueued immediately.
  • Nothing runs until flushQueued(ctx) is called.
  • Multiple queued reactions are executed in the order they were added, duplicates are filtered by hash.

When to use

  • Unit tests that want deterministic timing without a frame loop.
  • Command-line tools that need to flush after processing a batch of input lines.
  • Situations where you need to guarantee that expensive work is delayed until an explicit sync point.

Pitfalls

  • Forgetting to call flushQueued(ctx) causes reactions and autosave to accumulate indefinitely.
proc watch[T](ctx: ReactiveCtx; selector: () -> T;
              handler: proc (newVal, oldVal: T); immediate = false)

Observes the output of selector and calls handler whenever that output changes.

Workflow

  1. selector runs inside an internal effect, collecting dependencies and storing its result.
  2. When any dependency changes the effect reruns; if the new result differs from the stored one handler(new, old) is invoked.
  3. The stored result is updated to the new value.

immediate controls whether the handler fires on the first evaluation.

Use cases

  • React when a numeric value crosses a threshold without caring about every intermediate change.
  • Run validation logic only when a composite selector result differs.
  • Trigger animations where only the final state of a batch matters.

Example

watch(ctx,
  selector = () => hp.val div 10,
  handler  = proc(new, old: int) =
    echo "tens digit changed from", old, "to", new,
  immediate = true)

Pitfalls

  • Selector results are compared with !=. For complex types ensure that this equality test reflects real changes.
  • If the selector performs heavy work consider wrapping it in memo first.
  • handler runs inside the same flush that detected the change; avoid long running code that blocks other reactions.
proc `xor`[T: SomeInteger](a, b: Signal[T]): Signal[T]
proc `xor`[T: SomeInteger](a: Signal[T]; b: T): Signal[T]
proc `xor`[T: SomeInteger](a: T; b: Signal[T]): Signal[T]

Iterators

iterator items[T](rs: ReactiveSeq[T]): T

Yields each element of a ReactiveSeq as a plain value, from index 0 to len(rs) - 1. Reading through this iterator does not establish reactive dependencies, because it reads the cached .val of each element without registering the iterator as an observer.

Use it to build snapshots or aggregate results without causing future updates to rerun the loop. If you need an effect to rerun when elements change, iterate with explicit for i in 0 ..< len(rs): discard rs[i].val so each element signal is tracked.

iterator values[K, V](rt: ReactiveTable[K, V]): V

Yields the plain value of every entry in a ReactiveTable, in key order as produced by the container’s iterator. Like items on ReactiveSeq this iterator does not establish dependencies; it simply reads signal.val for each stored value and returns the result.

Ideal for one-off summarisation or JSON export. If you need live reactivity over the values, iterate keys and read rt[key].val explicitly so each value signal is tracked.

Macros

macro reactive(T: typedesc): untyped
Builds a reactive wrapper for the value object T together with three helper procedures:
  • `toReactive(ctx, src)` – builds the wrapper from a plain value.
  • `toPlain(self)` – converts the wrapper back to a plain value.
  • `set(self, src)` – overwrites the wrapper with a new plain (object) value while integrating with undo/redo, transactions, and variant switching.

Why you need it

  • Treats every field of T as a reactive value without writing any boiler-plate.
  • Preserves object variants (case sections) and warns when a discriminator change drops dependent signals.
  • Keeps plain data and reactive wrappers strictly separate so external code can stay unaware of the reactive layer.

Pitfalls

  • T must be a value object; passing a ref object aborts compilation.
  • The generated wrapper is itself a ref type; copying the variable copies the reference, not the data.
  • Discriminator changes fully rebuild the wrapper; signals that vanish cannot be migrated automatically and only trigger a warning.

Templates

template `$`(s: Signal): untyped
template `*=`[T](s: Signal[T]; d: T)
template `+=`[T](s: Signal[T]; d: T)
template `-=`[T](s: Signal[T]; d: T)
template `[]`(n: JsonNodeReactive; idx: int): untyped
template `[]`(n: JsonNodeReactive; key: string): untyped
template `[]`(n: Signal[JsonNodeReactive]): untyped
template `[]`(n: Signal[JsonNodeReactive]; idx: int): untyped
template `[]`(n: Signal[JsonNodeReactive]; key: string): untyped
template `[]`[T](rs: ReactiveSeq[T]; idx: int): untyped
template boolVal(n: JsonNodeReactive): Signal[bool]
template boolVal(n: Signal[JsonNodeReactive]): Signal[bool]
template defUnaryComplex(fname: untyped)
template floatVal(n: JsonNodeReactive): Signal[float]
template floatVal(n: Signal[JsonNodeReactive]): Signal[float]
template getField(jr: JsonNodeReactive; k: string): JsonNodeReactive
Read (and auto-create) an object member as a reactive node.
template intVal(n: JsonNodeReactive): Signal[int]
template intVal(n: Signal[JsonNodeReactive]): Signal[int]
template len(rs: ReactiveSeq): untyped
Returns the current element count of a ReactiveSeq and registers the caller as a subscriber. Any structural change (push, insert, remove, clear) updates the length signal and reruns effects that read it. Use this inside effects or computed signals when later mutations should trigger reactive updates.
template len(rt: ReactiveTable): untyped
Returns the number of key-value pairs stored in a ReactiveTable and tracks the caller as a subscriber. Any structural change (put, delKey, clear) updates the length signal and reruns dependent effects or computed values. Ideal for counters and list-rendering logic that must stay in sync with the table’s size.
template peek[T](s: Signal[T]): untyped

Reads a signal’s current value without registering a dependency. Useful inside effects when you need the data but do not want the effect to rerun on future changes.

effect(ctx, () =>
  echo "debug:", peek(hp)   # prints once, never reruns
)

template peekLen(rs: ReactiveSeq): untyped
Returns the length of a ReactiveSeq without tracking the caller as a subscriber. Handy for quick size checks in places where you do not want to establish reactivity.
template peekLen(rt: ReactiveTable): untyped
Returns the entry count of a ReactiveTable without tracking a dependency. Use when you need the value once and do not want the caller to rerun on structural changes.
template peekRev(rt: ReactiveTable): untyped
Returns the revision counter of a ReactiveTable without tracking a dependency. Suitable for diagnostic logs or conditional code paths that should not trigger on future table mutations.
template push(jr: JsonNodeReactive; child: JsonNodeReactive)
Append a child to an array-node.
template rev(rs: ReactiveSeq): untyped
Returns the structural revision counter of a ReactiveSeq and tracks the caller as a subscriber. The counter increments every time the sequence structure changes (push, insert, remove, clear) regardless of whether the overall length changed. Useful when you need to rerun an effect on any structural edit, even those that keep the length constant, such as swapping two elements.
template rev(rt: ReactiveTable): untyped
Returns the structural revision counter of a ReactiveTable and tracks the caller as a subscriber. The counter increments on every key set, key removal, or clear, allowing an effect to react to any structural mutation even when the entry count stays the same.
template rlog(parts: varargs[string, `$`])
template strVal(n: JsonNodeReactive): Signal[string]
template strVal(n: Signal[JsonNodeReactive]): Signal[string]
template transaction(ctx: ReactiveCtx; body: untyped)

Runs body while incrementing ctx.batching. Each nested call further increments the counter. Reactions, history entries, and autosave writes are suppressed until the counter returns to zero at the end of the outermost transaction.

What is coalesced

  • Signal writes still update their stored value immediately but notifications go into ctx.queue instead of running right away.
  • ReactiveSeq / ReactiveTable structural edits save an old copy on the first mutation and postpone emitting their HistoryEntry until the commit phase. Multiple edits of the same container collapse into one entry.
  • Undo/redo depth therefore grows by at most one entry per distinct signal and one per structurally mutated container, no matter how many writes happened inside the block.

Commit phase

When the outermost transaction exits (ctx.batching becomes zero) the template:

  1. hands a flush request to the current scheduler
  2. the scheduler later calls flushQueued(ctx) which
    • executes queued reactions once each
    • writes one autosave snapshot if dirty

Example

transaction(ctx):
  hp -= 10
  mana -= 5
  inv.push "potion"
# exactly one reaction run and one history record for inv

Nesting and exceptions

  • Nested transactions simply increment the counter further; commit runs only when the outermost layer finishes.
  • The template uses try/finally so ctx.batching is decremented even if body raises. An exception aborts the commit; partial changes stay applied because the engine has no automatic rollback. Call undo in a custom catch block if you need an all-or-nothing effect.

Pitfalls

  • Blocking flushQueued(ctx) inside the same frame when using a frame scheduler negates batching benefits.
  • Long running code inside body can delay reactions longer than users expect.
template val(s: Signal): untyped

Expands to s.value and performs dependency tracking.

When this template is used inside effect, computed, memo, or watch the call runs through registerDep(s) first. That records the current observer callback as a subscriber of the signal, so any future set on the same signal will schedule the observer to run again.

Relationship to other helpers

  • val(s) (this template) -> read and track
  • peek(s) -> read without tracking

Choose val when you want reactivity; choose peek when you only need the value once during the current execution.

Typical pattern

ctx.effect proc =
  echo "HP:", hp.val, "/", maxHp.val   # both reads tracked

Pitfalls

  • Calling val outside an observer context (for example in plain top-level code) still works but adds no subscription; if the read must stay reactive place it in an effect or computed signal.
  • Reading inside a loop in an effect collects a dependency for every iteration. If the loop length can grow unbounded this may create a large subscriber list; consider using structural signals like rev(rs) instead.
template watchNow[T](ctx: ReactiveCtx; selector: () -> T;
                     handler: proc (newVal, oldVal: T))

Shorthand for watch with immediate = true. Runs selector immediately, stores the result, invokes handler(new, new) once, and then behaves like watch, calling handler(new, old) whenever the selector’s output changes.

Helpful for loading indicators or log statements that should fire right away and on every subsequent change without writing two separate calls.