Skip to content

Graph

Lazy reactivity for Luau (push-pull / fine-grained reactivity). Observable primitives auto-track dependencies and recompute only what's read.

Primitives: Graph.signal(value, equals?) -- writable source Graph.computed(fn, equals?) -- derived value, recomputed on demand Graph.effect(fn) -- side-effecting reaction, run on flush Graph.new / Graph(obj, isEffect?, equals?) -- factory dispatching to the above

Node API (callable, with arithmetic/comparison metamethods): node:get() / node() -- read, tracked node:peek() -- read, untracked node:set(v, force?) / node(v) -- write node:onDestroy(fn) / node:Destroy()

Model: Nodes are CLEAN / CHECK / DIRTY. Writes push staleness to observers; reads pull values current, walking sources to skip redundant recomputation. Effects queue and run via flush(). Equality (default ~=) gates propagation.

Ownership: Reactions (computeds/effects) and scopes form an owner tree; plain signals do NOT join it: a signal is never owned and is reclaimed by the GC once unreferenced. Disposing a reaction/scope tears down its children, running cleanup()/onDestroy() callbacks. scope()/withOwner()/getOwner() manage owners; untrack()/retrack() control tracking. Disposal strips a node's inner table but keeps its wrapper + metatable (isNode stays true); the emptied wrapper is left for the GC. onDestroy fires only on explicit/owner disposal, never from GC.

Strict mode: When enabled, every reactive scope (computeds AND effects) runs a full second time to surface non-idempotent bodies in development. strict(on) swaps the update pointer between the lean single-run path and the double-run path; the latter also errors on reactive cycles (re-entering an active scope) and on destroying the scope that is currently running.

Types

node type

luau
type node = { any }

NodeData type

luau
type NodeData<T> = { [typeof(NODE_KEY)]: { T } }

Node type

luau
type Node<T> = setmetatable<NodeData<T>, typeof(Node)>

Reactive type

luau
type Reactive<T> = T | Node<T> | (() -> T)

Functions

strict

luau
Graph.strict(on: boolean?): boolean

Setter/getter for dev-mode strict checks. Pass true/false to set the flag, call with no argument to read it; defaults on in development, off under production. When on:

  • every reactive scope (computeds and effects alike) runs a full second time, surfacing non-idempotent bodies in dev.
  • reactive cycles (re-entering a running scope) and destroying the running scope error.
  • instance binding writes are checked individually: a failure reports the instance, key, and value, and no longer skips the node's remaining bindings.

Open Documentation

new

luau
Graph.new<T>(obj: T, isEffect: boolean?, equals: ((T, T) -> boolean)?): Node<T>

Creates a new reactive Node, the unified constructor: a signal when obj is a plain value, a computed/effect when it's a function. (Roblox Instance dispatch lives in Bind.node.)

Parameters

  • obj: The initial value, or a computation function.
  • isEffect: When obj is a function, true makes it an effect.
  • equals: An optional equals to short-circuit propagation.

Returns

  • Node: The newly created reactive Node.

signal

luau
Graph.signal<T>(initial: T, equals: ((T, T) -> boolean)?): Node<T>

Creates a writable signal holding initial.

  • Reading it inside a reaction subscribes; writing a new value re-runs dependents.
  • An optional equals short-circuits propagation when the new value compares equal to the old.
  • A signal is NOT owned by the enclosing scope (unlike a computed/effect); it is reclaimed by the GC when you drop it, not torn down on scope destroy.

Open Documentation

computed

luau
Graph.computed<T>(fn: () -> T, equals: ((T, T) -> boolean)?): Node<T>

Creates a lazily-cached derived node from fn.

  • it re-evaluates only when read after a tracked dependency changed
  • an optional equals short-circuits propagation on an equal result

Open Documentation

effect

luau
Graph.effect<T>(fn: (T) -> ()): Node<T>

Creates an effect node derived from fn.

  • deferred: queues fn to run on the next flush, then re-runs it whenever a tracked dependency changes
  • returns the effect node; call node:Destroy() to stop it

Open Documentation

flush

luau
Graph.flush(index: number?)

Drains the pending-effect queue immediately, instead of waiting for the deferred flush. Mainly useful in tests to make effects fire synchronously after a write.

Open Documentation

scope

luau
Graph.scope<T>(fn: (scope: Node<any>) -> T): (Node<any>, T)

Runs fn in a fresh owner scope, returning (scope, result): the scope node and whatever fn returned. Computeds, effects, instances, and cleanup callbacks created inside are owned by that scope and torn down together when you call scope:Destroy(); plain signals are not owned and are simply left for the GC.

Open Documentation

cleanup

luau
Graph.cleanup(obj: any)

Registers fn to run right before the enclosing reactive scope re-evaluates (and once more when it is destroyed), letting an effect tear down whatever it set up on the previous run: disconnect events, cancel work, release instances.

  • Must be called from inside a computed or effect body; fn receives the node's prior value.

Open Documentation

getOwner

luau
Graph.getOwner(): node?

Returns the current owner scope (a node), or nil at the root.

  • Capture it to re-establish ownership in deferred or async work via Flux.withOwner.

Open Documentation

withOwner

luau
Graph.withOwner<T>(node: Node<any>, fn: () -> ...T): T

Runs fn with node installed as the current owner scope, so the computeds, effects, and cleanup callbacks created inside attach to it and tear down on its lifetime; dependency tracking is left untouched (tracking-transparent).

Open Documentation

untrack

luau
Graph.untrack<T>(fn: (() -> ...T)?): ...T

Suspends dependency tracking for the duration of fn (reads inside don't subscribe the enclosing computed/effect).

  • Called bare as untrack() it suspends imperatively until a matching track() or the next update().

Open Documentation

retrack

luau
Graph.retrack()

Re-enables dependency tracking after a bare Flux.untrack() call suspended it.

  • Use the imperative untrack() / track() pair to fence a run of un-tracked reads inside a computed or effect.

Open Documentation

raw

luau
Graph.raw(object: any): any

Reads a node's current value without tracking it; a non-node object passes straight through, so it is safe to call on a prop that may or may not be reactive.

Open Documentation

read

luau
Graph.read(object: any): any

Reads a node's current value with tracking, just like node:get(); a non-node object passes straight through, making it the idiomatic way to read a prop that may be a plain value or a node.

Open Documentation

readDep

luau
Graph.readDep(object: any): any

rawDep

luau
Graph.rawDep(object: any): any

isNode

luau
Graph.isNode(object: unknown): boolean

Returns true when obj is a reactive node. Use it to branch on node-or-value props inside a component before deciding whether to read reactively.

Open Documentation

isReactive

luau
Graph.isReactive(): boolean

Returns true when called from within a tracking computed or effect body, where reads subscribe the running computation.

destroy

luau
Graph.destroy<T>(node: NodeData<T>)

Destroys a node, severing every reactive link to its sources and observers, running any pending Flux.cleanup and node:onDestroy callbacks.

  • The node's internal table is stripped, so a later read returns nil. Its wrapper and metatable are left intact (so Flux.isNode still returns true); the emptied wrapper is reclaimed by the garbage collector once unreferenced.

Open Documentation

onDestroy

luau
Graph.onDestroy(fn: () -> (), scope: Node<any>?)

Node

peek

luau
Graph.Node.peek<T>(self: Node<T>): T

Reads a node's up-to-date value WITHOUT subscribing the caller.

Open Documentation

get

luau
Graph.Node.get<T>(self: Node<T>): T

Reads the node's current value. This is a tracked read: when called inside a computed or effect it auto-subscribes the calling computation, so the caller re-runs whenever this node changes. To read without subscribing, use node:peek().

Open Documentation

set

luau
Graph.Node.set<T>(self: Node<T>, value: T, force: boolean?): T

Writes a value, skipping propagation when it is identical (~=) to the current one. Pass force as true to notify dependents anyway; to re-fire dependents without writing at all, use node:update().

Open Documentation

update

luau
Graph.Node.update<T>(self: Node<T>): Node<T>

Re-fires the node's dependents without writing a value: observers are marked stale exactly as if a changed value had been set. This is the terse equivalent of node:set(node:peek(), true), the idiomatic way to push an in-place table mutation (same reference) back through the graph. Returns the node, so a read or write can be chained.

Open Documentation

Destroy

luau
Graph.Node.Destroy<T>(self: Node<T>)

Destroys a node, severing every reactive link to its sources and observers, running any pending Flux.cleanup and node:onDestroy callbacks.

  • The node's internal table is stripped, so a later read returns nil. Its wrapper and metatable are left intact (so Flux.isNode still returns true); the emptied wrapper is reclaimed by the garbage collector once unreferenced.

Open Documentation

onDestroy

luau
Graph.Node.onDestroy<T>(self: Node<T>, fn: (oldValue: T) -> ())

Registers fn to run when the node is destroyed: via node:Destroy(), or, for a computed/effect, when its owning scope is. A plain signal has no owner, so its fn fires only on an explicit node:Destroy() (otherwise the signal is just GC'd, silently). Stacks across calls and, unlike Flux.cleanup, persists across re-runs.

Open Documentation