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 is fundamentally different from frameworks like React or Roact. If you understand how SolidJS components work, you will feel right at home here.
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,
}),
}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 Value). There are two 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.
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. Manual check with Flux.isNode
For targeted checks on a single prop:
local function StatusLabel(props)
return new "TextLabel" {
Text = function()
return if Flux.isNode(props.status) then props.status() else props.status
end,
}
endComponent Scopes and Lifecycles
If your component creates internal reactive state, asynchronous operations, or requires cleanup logic, manage its memory with a Scope.
Internal Component Scope
Create a scope internally and tie its lifetime to the root instance's __CLEAN directive. When Flux destroys the instance, it will destroy the scope, and all the nodes and effects inside it, automatically.
local function TimerComponent(props)
-- 1. Create a local scope for this component's memory
local localScope = Flux.scope()
-- 2. Internal state tracked by this scope
local timeElapsed = localScope(0)
-- 3. Internal effect tracked by this scope
localScope(function()
print("Timer is at: " .. timeElapsed)
end, true)
return new "TextLabel" {
Text = function() return timeElapsed .. "s" end,
-- 4. Destroy the scope when this TextLabel is destroyed
__CLEAN = {
function() localScope:Destroy() end,
},
}
endExternal Scope (Props)
If a parent is managing its own scope, accept it as a prop and create your nodes through it. This allows the parent to clean up the entire tree by destroying its scope.
local function AvatarCard(props)
-- props.scope is a Scope object passed from the parent
local name = props.scope(props.initialName)
return new "Frame" {
new "TextLabel" {
Text = name,
},
}
endBy adhering to the "Run Once" mental model and leveraging Scopes for memory management, you can build massive, highly performant UI trees out of small, predictable components.