Skip to content

Home › Concepts

Tracking ​

Whenever a Computed, Effect, or property binding runs, Flux automatically subscribes it to every node it reads. These utilities let you read values that may or may not be reactive, peek at state without subscribing, or lock a reaction to an explicit dependency list.

UtilitySubscribes Caller?AcceptsTypical Use
Flux.read(value)YesNode, getter, or plain valueReading Flux.Reactive<T> component props
Flux.raw(value)NoNode, getter, or plain valuePeeking at a Node, getter, or value without tracking
Flux.untrack(fn)No() -> ...TRunning a block of code without tracking reads
Flux.untrack() / Flux.retrack()NoNoneAllocation-free untracked fence in hot paths or around task.spawn
Flux.on(deps, fn, defer?)Declared deps onlyNode/getter or array of themRunning fn untracked whenever deps change
Flux.isReactive()-NoneChecking whether code is currently running inside a tracked reaction

Reactive Bodies Must Be Synchronous

Never yield (task.wait, :GetAsync(), :WaitForChild()) inside a Computed or Effect body. Use Flux.async for yielding work, or spawn a thread fenced by Flux.untrack() / Flux.retrack().

Flux.read ​

Reads a Flux.Reactive<T> with tracking. It returns node() for a Node, calls fn() for a getter, and returns plain values unchanged. Its return type is Flux.Unwrap<T>:

luau
local function StatusLabel(props: { text: Flux.Reactive<string> })
	return Flux.new "TextLabel" {
		Text = function()
			return string.upper(Flux.read(props.text))
		end,
	}
end

Flux.raw ​

Reads a Flux.Reactive<T> without tracking. It returns node:peek() for a Node, runs a getter with tracking suspended, and returns plain values unchanged:

luau
local health = Flux.signal(100)
local damage = Flux.signal(0)

Flux.effect(function()
	-- Re-runs when `damage` changes, but NOT when `health` changes
	print(`Took {damage()} damage at {Flux.raw(health)} HP`)
end)

Flux.untrack ​

Flux.untrack(fn) runs fn with dependency tracking suspended and returns all of its results, while preserving the current Scope owner so Flux.cleanup still attaches to the enclosing reaction:

luau
local selectedId = Flux.signal(1)
local namesById = Flux.signal({ "Ada", "Grace" })

Flux.effect(function()
	local id = selectedId() -- tracked
	local name = Flux.untrack(function()
		return namesById()[id] -- untracked
	end)
	print(id, name)
end)

Imperative Pairing: Flux.untrack() / Flux.retrack() ​

Calling Flux.untrack() with no arguments suspends tracking for the rest of the current function run or until Flux.retrack() is called. It allocates no closure and is the idiomatic way to fence a task.spawn call inside an Effect (since a spawned thread runs synchronously up to its first yield):

luau
local destination = Flux.signal(Vector3.zero)

Flux.effect(function()
	local target = destination()

	Flux.untrack()
	task.spawn(function()
		walkTo(target) -- any reactive reads before the first yield won't subscribe the effect
	end)
	Flux.retrack()
end)

Flux.on ​

Flux.on(deps, fn, defer?) wraps fn so only deps (a single Node/getter or an array of them) are tracked, while fn itself runs untracked. fn receives (input, previousInput, previousResult). When deps is an array, input and previousInput are arrays of their values:

luau
local trigger = Flux.signal(1)
local multiplier = Flux.signal(10)

-- Re-runs ONLY when `trigger` changes (`multiplier` is read untracked)
local total = Flux.computed(Flux.on(trigger, function(value, previousValue)
	return value * multiplier()
end))

Pass true as the third argument (defer) to skip the first run and execute fn only on subsequent changes to deps (returning nil on the skipped initial run):

luau
local health = Flux.signal(100)

Flux.effect(Flux.on(health, function(current, previous)
	print(`Health changed from {previous} to {current}`)
end, true))