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
type node = { any }NodeData type
type NodeData<T> = { [typeof(NODE_KEY)]: { T } }Node type
type Node<T> = setmetatable<NodeData<T>, typeof(Node)>Reactive type
type Reactive<T> = T | Node<T> | (() -> T)Functions
strict
Graph.strict(on: boolean?): booleanSetter/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.
new
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: Whenobjis a function,truemakes it an effect.equals: An optional equals to short-circuit propagation.
Returns
Node: The newly created reactive Node.
signal
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
equalsshort-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.
computed
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
equalsshort-circuits propagation on an equal result
effect
Graph.effect<T>(fn: (T) -> ()): Node<T>Creates an effect node derived from fn.
- deferred: queues
fnto run on the next flush, then re-runs it whenever a tracked dependency changes - returns the effect node; call
node:Destroy()to stop it
flush
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.
scope
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.
cleanup
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;
fnreceives the node's prior value.
getOwner
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.
withOwner
Graph.withOwner<T>(node: Node<any>, fn: () -> ...T): TRuns 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).
untrack
Graph.untrack<T>(fn: (() -> ...T)?): ...TSuspends 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 matchingtrack()or the nextupdate().
retrack
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.
raw
Graph.raw(object: any): anyReads 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.
read
Graph.read(object: any): anyReads 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.
readDep
Graph.readDep(object: any): anyrawDep
Graph.rawDep(object: any): anyisNode
Graph.isNode(object: unknown): booleanReturns 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.
isReactive
Graph.isReactive(): booleanReturns true when called from within a tracking computed or effect body, where reads subscribe the running computation.
destroy
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 (soFlux.isNodestill returnstrue); the emptied wrapper is reclaimed by the garbage collector once unreferenced.
onDestroy
Graph.onDestroy(fn: () -> (), scope: Node<any>?)Node
peek
Graph.Node.peek<T>(self: Node<T>): TReads a node's up-to-date value WITHOUT subscribing the caller.
get
Graph.Node.get<T>(self: Node<T>): TReads 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().
set
Graph.Node.set<T>(self: Node<T>, value: T, force: boolean?): TWrites 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().
update
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.
Destroy
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 (soFlux.isNodestill returnstrue); the emptied wrapper is reclaimed by the garbage collector once unreferenced.
onDestroy
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.