Components
As your application grows, you will want to break down complex UI hierarchies into smaller, reusable pieces. In Flux, Components are just regular Luau functions that return Roblox instances.
Because Flux does not rely on a Virtual DOM, its component model differs from libraries like React or Roact. If you understand how SolidJS components work, the model here will be familiar.
The "Run Once" Mental Model
The most critical concept to understand is that Flux components only run once.
A component is a factory function. When you call it, it creates Roblox instances, wires up reactive bindings (Computeds, Effects), and returns the generated UI. The component function itself does not re-execute when state changes. Only the specific property bindings (the functions or nodes you assign to properties like Text or BackgroundColor3) will re-evaluate.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Flux = require(ReplicatedStorage.Flux)
local new = Flux.new
-- This function runs exactly ONCE per button instance created
local function PrimaryButton(props)
print("Mounting button...") -- prints only once
return new "TextButton" {
Size = UDim2.fromOffset(200, 50),
BackgroundColor3 = Color3.fromRGB(0, 120, 255),
-- Only this re-evaluates when props.text changes
Text = props.text,
Activated = props.onClick,
}
endConsuming Components
Because components are just functions, you consume them by calling them within your declarative hierarchy.
local count = Flux(0)
local ui = new "Frame" {
Size = UDim2.fromScale(1, 1),
-- Mount the component by calling it
PrimaryButton({
text = Flux(function() return `Count: {count}` end),
onClick = function() count(count + 1) end,
}),
}Interpolating {count} (or writing count + 1) reads the node's current value automatically; see Signals.
Handling Props (Static vs. Reactive)
When designing a reusable component, you often don't know if the caller will pass a static value (like "Submit") or a reactive node (like a Flux Signal). There are a few clean ways to handle this.
1. Pass nodes directly to property bindings
The simplest approach: accept both static values and nodes as props, and pass them directly to the instance's property. Flux's declarative builder handles both; a static value is assigned once, a node is bound reactively.
local function StatusLabel(props)
return new "TextLabel" {
-- If props.status is a node, it binds reactively.
-- If it's a static string, it's assigned once.
Text = props.status,
}
end
-- Works with a static string:
StatusLabel({ status = "Ready" })
-- Works with a reactive node:
StatusLabel({ status = someFluxNode })2. Normalising props with Flux.wrap
For components with many props where you want to guarantee all values are nodes (so you can always call them as functions), use Flux.wrap at the top of the component. It converts any primitive values to Nodes in-place and leaves existing Nodes untouched. It also recurses into nested tables, so a prop like props.theme = { color = ... } gets its inner values wrapped too.
local function Card(props)
-- Normalise all props: primitives become Nodes, existing Nodes are unchanged
Flux.wrap(props)
return new "Frame" {
BackgroundColor3 = function()
return props.color() -- always safe to call
end,
new "TextLabel" {
Text = function()
return props.title() -- always safe to call
end,
},
}
end3. Reading node-or-value props with Flux.read
When you need a prop's value inside a larger expression, Flux.read evaluates a node (registering it as a dependency) or passes a plain value through unchanged:
local function StatusLabel(props)
return new "TextLabel" {
Text = function()
return "Status: " .. Flux.read(props.status)
end,
}
endFor an explicit check, Flux.isNode(props.status) tells you whether a prop is reactive. See Tracking for the full set of read utilities.
Accepting Children
A reusable container often needs to wrap whatever content the caller passes in. Accept those children as a prop (an array of instances), and place that array at a numeric position in your hierarchy. Flux flattens arrays into children in place, so there is no table.unpack and no manual parenting (see Creating Hierarchies).
local function Panel(props)
return new "Frame" {
Size = UDim2.fromOffset(400, 300),
BackgroundColor3 = Color3.fromRGB(30, 30, 35),
new "TextLabel" { Text = props.title }, -- a child of your own
props.children, -- the caller's array, flattened in place
}
end
Panel({
title = "Settings",
children = {
new "TextLabel" { Text = "Row 1" },
new "TextLabel" { Text = "Row 2" },
},
})Each element keeps the automatic LayoutOrder numbering Flux gives every child, so they render in array order. When the set of children changes over time, hand that same slot a Flux.forValue node instead of a static array; it occupies the same position and Flux manages parenting, ordering, and cleanup as the source list changes.
Component Scopes and Lifecycles
If your component creates internal reactive state, asynchronous operations, or requires cleanup logic, manage its memory with a Scope.
Ownership Flows Implicitly
When a component is called inside a parent's scope, everything it creates (nodes, effects, instances) is automatically owned by that scope. You never thread a scope through props; destroying the parent tears the whole component tree down with it.
Flux.scope(function()
-- Everything AvatarCard builds is owned by this scope
local card = AvatarCard({ name = "Player1" })
card.Parent = playerGui
end)A Self-Contained Component Scope
When a component owns resources whose lifetime should follow its own root instance rather than an outer scope, give it its own scope and tie that scope to the instance with _CLEAN. When Flux destroys the instance, it destroys the scope and everything inside it.
local function TimerComponent(props)
local _, label = Flux.scope(function(scope)
-- Internal state and effect (the effect is owned by this scope)
local timeElapsed = Flux(0)
Flux(function()
print("Timer is at: " .. timeElapsed)
end, true) -- the `true` makes this an Effect (see [Effects](/guide/concepts/effects))
return new "TextLabel" {
Text = function() return timeElapsed .. "s" end,
-- Destroy the scope when this TextLabel is destroyed
_CLEAN = { scope },
}
end)
return label
endBecause the TextLabel is built inside the scope, the scope owns it, and _CLEAN = { scope } closes the loop the other way: destroying the TextLabel (or any outer scope) tears down the timer's effect too; its timeElapsed signal is then left for the GC.
By following the "Run Once" mental model and letting scopes own what your components create, you can build large UI trees out of small, predictable components.