Scopes
In dynamic reactive systems, disposing of unused state, effects, and UI instances (disconnecting connections, destroying instances) prevents memory leaks. Scopes manage the lifecycles of groups of reactive objects without boilerplate.
If you are coming from other libraries, a Scope is a reactive root (like SolidJS's createRoot) combined with a cleanup tracker (similar to the Maid and Trove patterns common in Roblox development).
Creating a Scope
Flux.scope(fn) runs fn in a fresh owner and returns two values: the scope node and whatever fn returned. The reactions, instances, and cleanups created inside fn are owned by that scope, so a single scope:Destroy() tears them all down. (Plain signals are the exception; see What a Scope Owns.)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Flux = require(ReplicatedStorage.Flux)
local scope, label = Flux.scope(function()
local name = Flux("Guest")
-- This Effect is owned by the scope
Flux(function()
print("Hello, " .. name)
end, true)
-- This instance is owned by the scope
return Flux.new "TextLabel" {
Text = function() return "Welcome, " .. name() end,
Parent = playerGui,
}
end)
-- When this system is no longer needed:
scope:Destroy()
-- The Effect and `label` are disconnected and destroyed; `name`, a plain signal, is left for the GC.There is no separate "scoped" constructor. Inside a scope you build state and UI with the same Flux(...), Flux.new, and helpers you use everywhere else; ownership is tracked implicitly.
NOTE
fn also receives the scope node as its argument: Flux.scope(function(scope) ... end). Capture it when a nested callback needs to destroy the scope or re-enter it (see Capturing the Active Scope).
What a Scope Owns
Anything created while a scope is active that holds a subscription, a resource, or needs deterministic teardown attaches to it automatically:
- Computeds and Effects (
Flux.computed,Flux.effect,Flux(fn)) - Instances from
Flux.newand bindings applied withFlux.edit - Stores, Async tasks, Selectors, and mapping helpers
- Conditional branches from
Flux.showandFlux.switch
Destroying the scope tears all of them down. Nothing has to be registered by hand.
NOTE
Plain signals (Flux(value), Flux.signal) are the one exception: they hold no subscriptions or resources, so a scope does not own them. A signal is reclaimed by the garbage collector once you stop referencing it (not torn down on scope:Destroy()), which also means its onDestroy fires only on an explicit :Destroy().
Nesting Scopes
Scopes nest. Call Flux.scope inside another scope and the inner one becomes a child: destroying the parent destroys the child (and everything in it), while the child stays independently :Destroy()-able. Destroying a child first unlinks it from the parent, so a later parent destroy never double-frees it.
local session = Flux.scope(function()
-- A child scope for one screen within the session
local screen = Flux.scope(function()
return Flux.new "TextLabel" { Text = "Hello", Parent = playerGui }
end)
-- Tear down just this screen...
screen:Destroy() -- the label is gone; `session` is untouched
end)
-- ...or tear down everything at once
session:Destroy() -- destroys any remaining child scopes tooChild scopes are the building block behind conditional rendering: each mounted branch lives in its own child scope so it can be swapped out.
TIP
Reach for a child scope when a long-lived scope owns sub-regions that come and go independently: tabs, list rows, modals within a session. It keeps cleanup precise without juggling separate top-level scopes.
Registering Cleanup
For resources Flux does not create itself (manual connections, threads, third-party objects), Flux.cleanup attaches teardown to the current scope. It accepts anything cleanable: a function, an Instance, a connection, a thread, a node, or any object with a :Destroy()/:destroy() method.
Flux.scope(function()
Flux.cleanup(workspace.Marker) -- destroyed with the scope
Flux.cleanup(someSignal:Connect(handler)) -- disconnected with the scope
Flux.cleanup(function() print("bye") end) -- run on teardown
end)Inside a Computed or Effect, Flux.cleanup runs before each re-run as well as on destroy, which is how an effect tears down whatever it set up on its previous pass. See Effects.
Capturing the Active Scope
Inside any reactive body, Flux knows which scope owns the reactions and cleanups you create; that is how Flux.cleanup and implicit ownership find their target. Two low-level escape hatches let you read and re-establish that owner by hand, which matters when ownership would otherwise be lost across a boundary (a deferred callback, a coroutine, a manual helper):
Flux.getOwner()returns the scope node currently evaluating, ornilat the root. Capture it while you still have it.Flux.withOwner(node, fn)runsfnwithnodereinstalled as the active scope, so anything it creates (computeds, effects,Flux.cleanupcallbacks, instances) attaches to that scope's lifetime. It re-establishes only the owner, not dependency tracking; so reads insidefnstill subscribe the computation that is currently running. Combine it withFlux.untrackwhen those reads should not become dependencies (the built-in helpers pair the two).
local owner = Flux.getOwner() -- grab the active scope before we defer
task.defer(function()
-- Re-enter `owner` so the cleanup below attaches to it, tearing `late` down on teardown
Flux.withOwner(owner, function()
local late = Flux(buildExpensiveThing())
Flux.cleanup(function() late:Destroy() end)
end)
end)These are the primitives the built-in helpers (mapping, conditionals, instance binding) use internally to keep ownership correct; most application code only needs Flux.scope.
Manual Cleanup with Flux.clean
Flux.clean is the cleanup engine behind Scopes and the _CLEAN directive, exposed for manual use. It accepts a function, Instance, connection, thread, node, any object with a :Destroy()/:destroy() method, or an array of any of these (recursively), and disposes of it immediately. Individual failures are isolated, so one bad cleanup never aborts the rest:
Flux.clean({
connection,
someInstance,
function() print("bye") end,
})Flux.clean disposes now; Flux.cleanup defers disposal to the current scope's teardown.
When to Use Scopes
- UI components - Wrap each component in a scope. Destroy it when the component unmounts.
- Minigames or rounds - Create one scope per round. When the round ends, call
:Destroy()to wipe all round-specific UI, state, and effects instantly. - Player sessions - Tie a scope to
Players.PlayerAdded. Destroy it inPlayers.PlayerRemoving. - Modal windows - Create a scope when the window opens; destroy it when it closes.
Connecting a Scope to an Instance Lifetime
A common pattern ties a scope's lifetime to a Roblox instance with the _CLEAN directive, so destroying the instance destroys the scope:
local function Toast(message)
local scope, frame = Flux.scope(function(scope)
local alpha = Flux(1)
return Flux.new "Frame" {
BackgroundTransparency = function() return 1 - alpha() end,
Size = UDim2.fromOffset(300, 60),
-- Destroy the scope when the frame is destroyed
_CLEAN = { scope },
}
end)
-- Animate out and destroy after 3 seconds
task.delay(3, function()
frame:Destroy() -- triggers _CLEAN, which destroys the scope
end)
return frame
endThe Frame is owned by the scope, and _CLEAN = { scope } closes the loop the other way: if anything destroys the Frame (its parent ScreenGui going away, for example), the scope and all the state inside it go with it. Destroying either side tears the whole unit down exactly once.