Signals
Signals are the basic unit of reactive state in Flux: a node that holds a piece of data and notifies the graph whenever that data changes.
Creating a Signal
Call the main Flux function, with an initial value. Any non-function argument implicitly creates a signal.
NOTE
You can also use Flux.signal to explicitly create a signal.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Flux = require(ReplicatedStorage.Flux)
-- Create signals implicitly
local count = Flux(0)
local name = Flux("Roblox")
local isVisible = Flux(true)
-- Or create signals explicitly
local count = Flux.signal(0)
local name = Flux.signal("Roblox")
local isVisible = Flux.signal(true)Reading a Signal
Call the node with no arguments to read its current value. This call form, count(), is the idiomatic default; it is shorthand for the explicit count:get(). Reading a node inside a reactive context (a Computed or Effect body, or a function passed to a property binding) automatically registers it as a dependency.
local count = Flux(5)
print(count()) --> 5
print(count:get()) --> 5 (the explicit equivalent)Operator Overloading
Signals overload Luau's arithmetic and concatenation operators, so you can use them transparently in expressions; no need to call () at every use site.
local count = Flux(5)
print(count * 2) --> 10
print(count + 10) --> 15
print(-count) --> -5
print(count .. "x") --> "5x"
print(count > 3) --> attempt to compare number < table
print(count == 5) --> false (not true! == is identity, not value)These overloads call :get() under the hood, so they also register the node as a reactive dependency when evaluated inside a computation.
NOTE
Comparison has two gotchas. Relational operators (<, <=, >, >=) only work between two nodes: comparing a node against a primitive value (like a number) throws, due to a Luau restriction on the __lt/__le metamethods. Equality (==/~=) is not overloaded at all, so node == value tests table identity and is silently always false. In both cases, read the value first: count() > 3 or count() == 5.
Updating a Signal
Call the node with a new value to set it: count(10) is the idiomatic shorthand for the explicit count:set(10). Either form immediately updates the stored state and notifies any downstream nodes.
local count = Flux(0)
count(10) -- set to 10
count:set(5) -- the explicit equivalent, set to 5
count(count + 1) -- arithmetic reads the current value, then sets the resultIn count + 1, the + operator reads the node's current value automatically (see Operator Overloading below), so you do not have to call count() first.
NOTE
Calling a node with nil is a read, not a write; node(nil) is indistinguishable from node(). To set a signal to nil, use node:set(nil), or force the call shorthand with node(nil, true) (see Forcing an Update below).
Forcing an Update
Setting a signal to something equal to its current value skips propagation. Pass true as the second argument of :set() to notify dependents anyway. This is useful when you mutate a table in place and need to push the same reference through the graph:
local items = Flux({ "Sword" })
local list = items()
table.insert(list, "Shield")
items:set(list, true) -- same table reference; force dependents to updateWhen there is no new value to write, :update() is the terser equivalent: it re-fires dependents with the value the node already holds, and returns the node so a read or write can be chained.
table.insert(items:peek(), "Axe")
items:update() -- nothing written; dependents re-run with the same tableCustom Equality
By default, a node only propagates when the new value is ~= the old one. The explicit constructors Flux.signal and Flux.computed accept an optional equals function as their final argument; when it returns true, the values are considered equal and dependents are not notified:
-- Only propagate when the position moves beyond a fuzzy epsilon
local position = Flux.signal(Vector3.zero, function(a, b)
return a:FuzzyEq(b, 1e-3)
end)Binding to the UI
You can pass a signal (or any reactive node) directly to a property in Flux.new or Flux.edit. Flux detects the node and binds to it, and the property updates automatically whenever the node changes.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Flux = require(ReplicatedStorage.Flux)
local new = Flux.new
local count = Flux(0)
local button = new "TextButton" {
Size = UDim2.fromOffset(200, 50),
-- Direct node binding: the Text property mirrors count automatically
Text = count,
Activated = function()
count(count + 1)
end,
}For derived values you only need in one place, you can also pass an inline function. Flux wraps it in an implicit Computed for this binding:
local button = new "TextButton" {
-- Inline function: Flux creates an implicit Computed for this binding
Text = function()
-- `.. count` reads the node's current value automatically
return "Count: " .. count
end,
}Under the Hood
When Flux sees a reactive node or a function assigned to a property, it establishes a reactive binding. During the first evaluation, Flux tracks which nodes were accessed and maps the property as a dependent of those nodes.
Whenever count(newValue) is called, Flux propagates the change through the graph, ensuring your UI stays in sync without any manual .Changed or GetPropertyChangedSignal connections.
Binding from an Instance Property
The reverse direction also works: passing an Instance to the Flux constructor creates a signal that mirrors one of its properties, updating whenever the property changes. Prefix the name with $ to mirror an attribute instead:
-- A node that always holds the TextBox's current text
local text = Flux(textBox, "Text")
-- A node that mirrors the character's 'Health' attribute
local health = Flux(character, "$Health")
Flux(function()
print("User typed: " .. text)
end, true) -- the `true` makes this an Effect (see [Effects](/guide/concepts/effects))The mirror connection is disconnected automatically when the node is destroyed.
Destroying a Signal
Call :Destroy() to sever a node from the graph; all upstream and downstream links are removed. Use :onDestroy(fn) to register teardown logic that runs at that moment:
local count = Flux(0)
count:onDestroy(function()
print("count destroyed")
end)
count:Destroy()Destroying strips the node's stored value, so a later read returns nil, and unsubscribes it from the graph. The node object itself is simply left for Luau's garbage collector to reclaim once you drop your references, so Flux.isNode still reports true for a destroyed node. A signal is not owned by any scope, so :onDestroy(fn) runs only on an explicit :Destroy(); a signal you merely stop referencing is collected silently, without firing onDestroy. (A computed or effect, which a scope does own, fires onDestroy on that scope's teardown as well.)
In practice you rarely call :Destroy() on a signal by hand; an unreferenced signal is collected on its own, and a Scope tears down the reactions and resources it owns in a single call.