Home › Overview › MainModule › Flux
Graph
The reactive graph: signals hold values, computeds derive them lazily, effects react to them, and scopes own the reactions and cleanups created inside them. The documented members are flat on Flux.
local count = Flux.signal(0)
local doubled = Flux.computed(function()
return count() * 2
end)
Flux.effect(function() print(doubled()) end) -- prints 0, then 10 on the flush after `count(5)`
count(5)Types
Cleanable type
type Cleanable = (() -> ())
| ((oldValue: any) -> ())
| ((oldValue: any, destroyed: boolean) -> ())
| thread
| Instance
| RBXScriptConnection
| { read Destroy: (self: any) -> () }
| { read destroy: (self: any) -> () }
| { read Disconnect: (self: any) -> () }
| { read disconnect: (self: any) -> () }
| { read [number]: Cleanable }Context type
type Context<T> = {
read default: T,
--[[
Reads the current value: the innermost `provide` around the running code, falling back to the `default` when
none is active. A [computed](https://flux.kohl.gg/guide/concepts/computeds), [effect](https://flux.kohl.gg/guide/concepts/effects),
or [scope](https://flux.kohl.gg/guide/concepts/scopes) remembers the values provided where it was <em>created</em>, so its
later runs keep reading them.
[Open Documentation](https://flux.kohl.gg/guide/concepts/context#providing-and-reading)
]]
get: (self: any) -> T,
--[[
Runs `fn` with this context set to `value`, so any `get` reached during `fn` (no matter how deep) sees
`value`, and so do the later runs of every computed, effect, and scope created during `fn`. The previous value is
restored once `fn` returns or throws. Returns whatever `fn` returns.
[Open Documentation](https://flux.kohl.gg/guide/concepts/context#providing-and-reading)
]]
provide: <R>(self: any, value: T, fn: () -> R) -> R,
}Node type
type Node<T = any> = setmetatable<{
--[[
Reads the node's up-to-date value <strong>without</strong> subscribing the caller.
[Open Documentation](https://flux.kohl.gg/guide/concepts/signals#reading-a-signal)
]]
read peek: (self: any) -> T,
--[[
Reads the node's current value. This is a <strong>tracked</strong> read: inside a
[computed](https://flux.kohl.gg/guide/concepts/computeds) or [effect](https://flux.kohl.gg/guide/concepts/effects) it
subscribes the running reaction, which re-runs when this node changes. `node()` is the same read; `node:peek()` reads
without subscribing.
[Open Documentation](https://flux.kohl.gg/guide/concepts/signals#reading-a-signal)
]]
read get: (self: any) -> T,
--[[
Writes a value, skipping propagation when it equals the current one (`~=`, or the node's `equals`). Pass `force` as
`true` to notify dependents anyway; `node(value)` is the same write. Returns `value`.
[Open Documentation](https://flux.kohl.gg/guide/concepts/signals#updating-a-signal)
]]
read set: (self: any, value: any, force: boolean?) -> T,
--[[
Re-fires the node's dependents <strong>without writing</strong>, as if a changed value had been set: the way to push
an in-place table mutation (same reference) through the graph. Returns the node.
[Open Documentation](https://flux.kohl.gg/guide/concepts/signals#forcing-an-update)
]]
read update: <N>(self: N) -> N,
--[[
Destroys the node: severs every link to its sources and observers, then runs its pending `Flux.cleanup` values and
`node:onDestroy` callbacks. A later read returns `nil`; `Flux.isNode` stays `true`. Destroying twice is a no-op.
[Open Documentation](https://flux.kohl.gg/guide/concepts/signals#destroying-a-signal)
]]
read Destroy: (self: any) -> (),
--[[
Runs `fn` with the node's last value when the node is destroyed: by `node:Destroy()`, or with the scope that owns a
computed or effect. A signal has no owner, so only its own `Destroy` fires it. Unlike `Flux.cleanup`, it survives
re-runs.
[Open Documentation](https://flux.kohl.gg/guide/concepts/signals#destroying-a-signal)
]]
read onDestroy: (self: any, fn: (oldValue: T) -> ()) -> (),
}, Operators<T>>A reactive cell holding a T: a signal, computed, effect or scope. Call it to read (node()) or write (node(v)). Covariant, so a Node<"a"> is a Node<string> and a Node<string> a Node<string?>: the type props and parameters take. A write through a Node<T> is not checked against T; the State<T> a signal returns is.
State type
type State<T = any> = setmetatable<{
--[[
Reads the node's up-to-date value <strong>without</strong> subscribing the caller.
[Open Documentation](https://flux.kohl.gg/guide/concepts/signals#reading-a-signal)
]]
read peek: (self: any) -> T,
--[[
Reads the node's current value. This is a <strong>tracked</strong> read: inside a
[computed](https://flux.kohl.gg/guide/concepts/computeds) or [effect](https://flux.kohl.gg/guide/concepts/effects) it
subscribes the running reaction, which re-runs when this node changes. `node()` is the same read; `node:peek()` reads
without subscribing.
[Open Documentation](https://flux.kohl.gg/guide/concepts/signals#reading-a-signal)
]]
read get: (self: any) -> T,
--[[
Writes a value, skipping propagation when it equals the current one (`~=`, or the node's `equals`). Pass `force` as
`true` to notify dependents anyway; `node(value)` is the same write. Returns `value`.
[Open Documentation](https://flux.kohl.gg/guide/concepts/signals#updating-a-signal)
]]
read set: (self: any, value: T, force: boolean?) -> T,
--[[
Re-fires the node's dependents <strong>without writing</strong>, as if a changed value had been set: the way to push
an in-place table mutation (same reference) through the graph. Returns the node.
[Open Documentation](https://flux.kohl.gg/guide/concepts/signals#forcing-an-update)
]]
read update: <N>(self: N) -> N,
--[[
Destroys the node: severs every link to its sources and observers, then runs its pending `Flux.cleanup` values and
`node:onDestroy` callbacks. A later read returns `nil`; `Flux.isNode` stays `true`. Destroying twice is a no-op.
[Open Documentation](https://flux.kohl.gg/guide/concepts/signals#destroying-a-signal)
]]
read Destroy: (self: any) -> (),
--[[
Runs `fn` with the node's last value when the node is destroyed: by `node:Destroy()`, or with the scope that owns a
computed or effect. A signal has no owner, so only its own `Destroy` fires it. Unlike `Flux.cleanup`, it survives
re-runs.
[Open Documentation](https://flux.kohl.gg/guide/concepts/signals#destroying-a-signal)
]]
read onDestroy: (self: any, fn: (oldValue: T) -> ()) -> (),
}, Operators<T>>A writable source, as Flux.signal returns it: a Node<T> whose set is checked, so state:set(5) on a string state is an error (the call form state(5) is not checked, so typed code writes with :set). Exact in T; pass it wherever a Node<T> or Reactive<T> is taken.
Reactive type
type Reactive<T> = T | Node<T> | (() -> T)A value that may be reactive: a plain T, a node whose value fits T, or a getter returning a T. Flux.read reads any of them, and Flux.new binds any of them to a property. A generic helper should take any A and return Unwrap<A>: a <T>(value: Reactive<T>) parameter binds T to the node itself when given a node.
type Props = { label: Flux.Reactive<string>, disabled: Flux.Reactive<boolean>? }
local function Label(props: Props) end
local function label<A>(value: A): Flux.Node<Flux.Unwrap<A>>
return Flux.computed(function()
return Flux.read(value)
end)
endUnwrap type function
type function Unwrap(value: type)The value a Reactive reads as: a node's value, a getter's result, or the value itself. Distributes over unions, so Unwrap<Reactive<string>?> is string?. The result type of Flux.read and Flux.raw.
Owner type
type Owner = { any }Functions
new
Graph.new(obj: any, isEffect: boolean?, equals: any): anyCreates a node from obj: a signal for a value, a computed for a function, or an effect for a function when isEffect is true. A node is returned as is.
signal
Graph.signal(initial: any, equals: any): anyCreates a writable signal holding initial. Reading it inside a reaction subscribes; writing a new value re-runs dependents. equals replaces the ~= change check. A signal is never owned by a scope: the GC reclaims it once dropped.
local count = Flux.signal(0)
count(count() + 1)computed
Graph.computed(fn: any, equals: any): anyCreates a lazily cached node derived from fn. It re-evaluates only when read after a dependency changed, and equals stops propagation when the new result compares equal.
local total = Flux.computed(function()
return price() * quantity()
end)effect
Graph.effect(fn: (...any) -> ...any): anyCreates an effect: fn runs on the next flush and again whenever a node it read changes. Returns the effect node; node:Destroy() stops it, as does destroying the scope that created it.
Flux.effect(function() print("count is", count()) end)getter
Graph.getter(node: any): anyA plain function that reads node with tracking, like node(): for hot code, since calling it skips the wrapper's __call dispatch. It works anywhere a getter does (Flux.read, a binding, a prop).
local count = Flux.signal(0)
local getCount = Flux.getter(count)
Flux.effect(function()
print(getCount())
end)setter
Graph.setter(node: any): anyA plain function that writes node, like node:set(value, force) (so nil is a write): for hot code, since calling it skips the wrapper's __call dispatch.
createSignal
Graph.createSignal<T>(initial: T, equals: ((T, T: any) -> boolean)?): (() -> T, (value: T, force: boolean?) -> T)A signal without a node: returns a getter and a setter, like Solid's createSignal. The getter reads with tracking, like node(); the setter writes, like node:set(value, force) (so nil is a write). They are smaller and faster than a node from Flux.signal, and they work anywhere a getter does (Flux.read, a binding, a prop), but there are no methods: nothing to Destroy (the signal is collected once nothing holds the functions), no onObserved or Flux.dispose.
local count, setCount = Flux.createSignal(0)
Flux.effect(function()
print(count())
end)
setCount(count() + 1)createMemo
Graph.createMemo<T>(fn: () -> T, equals: ((T, T) -> boolean)?): () -> TA computed without a node: returns its getter, like Solid's createMemo. It is owned like Flux.computed (destroyed with the scope or reaction that created it) and reads like node(), but it has no methods.
local count, setCount = Flux.createSignal(1)
local doubled = Flux.createMemo(function()
return count() * 2
end)createEffect
Graph.createEffect(fn: (...any) -> ...any)An effect without a node: schedules fn on the next flush and re-runs it whenever a node it read changes, like Solid's createEffect. It is owned like Flux.effect (destroyed with the scope or reaction that created it), but returns nothing.
local count, setCount = Flux.createSignal(0)
Flux.createEffect(function()
print("count is", count())
end)flush
Graph.flush()Runs the pending effects now instead of on the next frame. Mainly for tests, to observe a write's effects synchronously.
scope
Runs fn in a new owner scope and returns (scope, result). The computeds, effects, instances and cleanup values created inside are torn down together by scope:Destroy().
local scope, frame = Flux.scope(function() return Flux.new "Frame" { Parent = gui } end)
scope:Destroy() -- destroys the frame and everything bound to itcleanup
Graph.cleanup(obj: Cleanable)Registers obj to be disposed, like Flux.dispose, right before the running computed or effect re-runs and when its owner is destroyed, after the owner's children are torn down in both cases. A function receives the owner's previous value and a destroyed boolean (true on final teardown, false on re-computation). Errors outside an owner, and on a value Flux.dispose can't tear down.
Flux.effect(function()
local connection = button.Activated:Connect(onClick)
Flux.cleanup(connection)
end)dispose
Graph.dispose(obj: Cleanable?)Disposes obj right now, the same teardown a scope runs on its Flux.cleanup values. A function is called with no arguments, an Instance is destroyed, a connection is disconnected, a suspended thread is canceled, a table is torn down by its Destroy/destroy/ Disconnect/disconnect method (a node, a scope, an async, a selector, a store) or else, when it is an array, each item in turn. It runs untracked, so reads inside a disposed function never subscribe the running reaction. An error is warned, never raised, and anything else (nil included) is ignored.
local connections = { part.Touched:Connect(onTouched), part.Destroying:Connect(onGone) }
Flux.dispose(connections)getOwner
Graph.getOwner(): Owner?Returns the current owner, or nil at the root. Capture it to create reactions from deferred work that still belong to it, with Flux.withOwner.
withOwner
Graph.withOwner(node: any, fn: () -> ...any): ...anyRuns fn with owner (from Flux.getOwner, or a node) as the current owner, so the reactions and cleanups created inside belong to it, and the context provided where it was created is visible again. Tracking is left as it is. Pass nil to create unowned roots. On an owner already destroyed (a late callback), what fn creates and registers is disposed as soon as fn returns or throws.
untrack
Graph.untrack(fn: (() -> ...any)?): ...anyRuns fn without tracking: reads inside don't subscribe the running computed or effect. Returns what fn returns. Called with no function, it stops tracking for the rest of the current run, or until Flux.retrack().
Flux.effect(function()
local id = selected() -- tracked
local name = Flux.untrack(function() return names()[id] end) -- not tracked
end)retrack
Graph.retrack()Resumes tracking after a bare Flux.untrack() in the same body, restoring exactly the reaction it suspended, so the pair fences a stretch of untracked reads. Without a matching bare untrack() (in a scope body, inside untrack(fn) or withOwner, or when tracking is already on) it changes nothing. Pair untrack() and retrack() within one function body: a getter read through Flux.raw does not get a slot of its own.
Flux.untrack()
local snapshot = rows()
Flux.retrack()raw
Graph.raw(object: any): anyReads a Reactive without tracking: a node's current value, a getter's result (the getter's own reads untracked too), or a plain value as is.
read
Graph.read(object: any): anyReads a Reactive with tracking: a node's value, a getter's result, or a plain value as is. The way to read a prop that may be any of them.
local function Label(props: { text: Flux.Reactive<string> })
return Flux.new "TextLabel" { Text = function() return string.upper(Flux.read(props.text)) end }
endsafe
Graph.safe<T>(tryFn: () -> T, fallback: T | (err: unknown) -> T, equals: ((a: T, b: T) -> boolean)?): Node<T>Creates a computed that evaluates tryFn and, if it throws, falls back to fallback, re-running and recovering automatically whenever its tracked dependencies change. fallback may be a static value or a recovery function receiving the caught error; a recovery function runs untracked so it cannot accidentally subscribe to extra dependencies, and if it throws in turn, its own error propagates unchanged. Pass equals to customize change detection (default ==).
context
Graph.context(default: any): anyCreates a context with the given default, read by any code built under a provide without threading it through arguments. Outside any active provide, reads return default. Pair with components to share theme, services, or the current user down a tree.
local Theme = Flux.context("dark")
Theme:provide("light", function() return App {} end) -- App and everything it builds reads "light"bindContext
Graph.bindContext<A..., R...>(fn: (A...) -> R...): (A...) -> R...Wraps fn so every call runs with the context values provided where it was wrapped, as a node created there would. Handlers declared in Flux.new/Flux.edit props get this already; use it for a connection made by hand, a task callback or a callback handed to other code, so a handler that opens a dialog draws it in the trigger's theme. Ownership and tracking are left as they are.
local onClick = Flux.bindContext(function()
print(ThemeContext:get()) -- the value provided where onClick was made
end)isNode
Graph.isNode(object: any): booleanWhether object is a node.
isReactive
Graph.isReactive(): booleanWhether the caller runs inside a tracking computed or effect, where reads subscribe it.
Node
Constructors: Graph.effect · Graph.scope
peek
Node:peek(): anyReads the node's up-to-date value without subscribing the caller.
get
Node:get(): anyReads the node's current value. This is a tracked read: inside a computed or effect it subscribes the running reaction, which re-runs when this node changes. node() is the same read; node:peek() reads without subscribing.
set
Node:set(value: any, force: boolean?): anyWrites a value, skipping propagation when it equals the current one (~=, or the node's equals). Pass force as true to notify dependents anyway; node(value) is the same write. Returns value.
update
Node:update(): anyRe-fires the node's dependents without writing, as if a changed value had been set: the way to push an in-place table mutation (same reference) through the graph. Returns the node.
Destroy
Node:Destroy()Destroys the node: severs every link to its sources and observers, then runs its pending Flux.cleanup values and node:onDestroy callbacks. A later read returns nil; Flux.isNode stays true. Destroying twice is a no-op.
onDestroy
Node:onDestroy(fn: (oldValue: any) -> ())Runs fn with the node's last value when the node is destroyed: by node:Destroy(), or with the scope that owns a computed or effect. A signal has no owner, so only its own Destroy fires it. Unlike Flux.cleanup, it survives re-runs.
onObserved
Node:onObserved(fn: () -> ())Runs fn when the node becomes observed: when its first observer arrives, directly or through computeds, so that some effect now depends on it. Pair it with node:onUnobserved to start and stop work (a connection, a poll) only while anything is listening. A computed counts as observed while an effect transitively reads it.
onUnobserved
Node:onUnobserved(fn: () -> ())Runs fn when the node no longer has any observers: no effect depends on it anymore, directly or through computeds.
Context
Constructors: Graph.context
get
Context:get<T>(): TReads the current value: the innermost provide around the running code, falling back to the default when none is active. A computed, effect, or scope remembers the values provided where it was created, so its later runs keep reading them.
provide
Context:provide<T, R>(value: T, fn: () -> R): RRuns fn with this context set to value, so any get reached during fn (no matter how deep) sees value, and so do the later runs of every computed, effect, and scope created during fn. The previous value is restored once fn returns or throws. Returns whatever fn returns.