Reactive Properties

Learn how to create dynamic UIs with Rex's universal reactivity system - direct state binding with automatic type conversion.

Last updated: 6/23/2025
Version: 0.2.0

Rex features a universal reactivity system that automatically detects and handles reactive values, making it incredibly simple to create dynamic UIs. You can bind state directly to any property, and Rex will automatically convert types and handle updates.

Universal Direct Binding

Rex’s universal reactivity means you can bind state objects directly to any property without manual conversion:

local function UniversalReactivityExample()
    local count = Rex.useState(42)
    local isVisible = Rex.useState(true)
    local message = Rex.useState("Hello, Rex!")
    local color = Rex.useState(Color3.fromRGB(255, 100, 50))
    
    return Rex("TextLabel") {
        -- Direct state binding - automatically reactive!
        Text = count,              -- Number → String (auto-converted)
        Visible = isVisible,       -- Boolean → Boolean (direct)
        BackgroundColor3 = color,  -- Color3 → Color3 (direct)
        Size = UDim2.fromScale(0.5, 0.5), -- Static value
        
        -- Even complex expressions work
        TextTransparency = isVisible:map(function(visible)
            return visible and 0 or 1
        end)
    }
end

What happens automatically:

  • Numbers are converted to strings for Text properties
  • Booleans work directly with Visible properties
  • Color3 values bind directly to color properties
  • Rex detects reactive values and sets up automatic updates
  • Static values are set normally

Auto-Type Conversion

Rex automatically converts between compatible types:

local function AutoConversionExample()
    local health = Rex.useState(75)      -- Number
    local isAlive = Rex.useState(true)   -- Boolean
    local level = Rex.useState(10)       -- Number
    
    return Rex("Frame") {
        children = {
            Rex("TextLabel") {
                -- Number automatically converts to string
                Text = health,  -- Shows "75"
                -- Boolean converts to visibility
                Visible = isAlive,
                Size = UDim2.new(1, 0, 0, 30)
            },
            Rex("TextLabel") {
                -- Complex auto-conversion
                Text = level,  -- Shows "10"
                -- Number can convert to transparency (0-1 range)
                BackgroundTransparency = health:map(function(h) return (100 - h) / 100 end),
                Position = UDim2.new(0, 0, 0, 35),
                Size = UDim2.new(1, 0, 0, 30)
            }
        }
    }
end

Supported auto-conversions:

  • numberstring (for Text properties)
  • booleanboolean (for Visible properties)
  • numbernumber (for transparency, size values)
  • Any type can use :map() for custom conversion

State Transformations with map

Use the map method to transform state values before applying them to properties:

local function TransformExample()
    local count = Rex.useState(0)
    local isVisible = Rex.useState(true)
    
    return Rex("TextLabel") {
        -- Transform number to string with formatting
        Text = count:map(function(value)
            if value == 0 then
                return "No items"
            elseif value == 1 then
                return "1 item"
            else
                return `{value} items`
            end
        end),
        
        -- Transform boolean to transparency
        BackgroundTransparency = isVisible:map(function(visible)
            return visible and 0 or 1
        end),
        
        -- Transform count to color (red when 0, green when > 0)
        BackgroundColor3 = count:map(function(value)
            return value > 0 and Color3.fromRGB(0, 255, 0) or Color3.fromRGB(255, 0, 0)
        end)
    }
end

Computed Properties

Use computed states for complex property calculations:

local function ComputedPropertiesExample()
    local health = Rex.useState(100)
    local maxHealth = Rex.useState(100)
    
    -- Computed health percentage
    local healthPercent = Rex.useComputed(function()
        return health:get() / maxHealth:get()
    end, {health, maxHealth})
    
    return Rex("Frame") {
        children = {
            -- Health bar background
            Rex("Frame") {
                Size = UDim2.new(1, 0, 0, 20),
                BackgroundColor3 = Color3.fromRGB(100, 100, 100),
                children = {
                    -- Health bar fill (reactive width)
                    Rex("Frame") {
                        Size = healthPercent:map(function(percent)
                            return UDim2.new(percent, 0, 1, 0)
                        end),
                        BackgroundColor3 = healthPercent:map(function(percent)
                            -- Red when low health, green when full
                            if percent > 0.6 then
                                return Color3.fromRGB(0, 255, 0)
                            elseif percent > 0.3 then
                                return Color3.fromRGB(255, 255, 0)
                            else
                                return Color3.fromRGB(255, 0, 0)
                            end
                        end)
                    }
                }
            },
            -- Health text
            Rex("TextLabel") {
                Text = healthPercent:map(function(percent)
                    return `{math.floor(percent * 100)}%`
                end),
                Size = UDim2.new(1, 0, 0, 30),
                Position = UDim2.new(0, 0, 0, 25),
                BackgroundTransparency = 1
            }
        }
    }
end

Deep State Properties

When using deep reactive state, you can bind to nested properties:

local function PlayerStatsExample()
    local player = Rex.useDeepState({
        name = "Player",
        level = 1,
        stats = {
            health = 100,
            mana = 50,
            experience = 0
        },
        appearance = {
            theme = "dark",
            color = Color3.fromRGB(70, 130, 255)
        }
    })
    
    return Rex("Frame") {
        -- Bind to nested properties using map
        BackgroundColor3 = player:map(function(p) return p.appearance.color end),
        
        children = {
            Rex("TextLabel") {
                Text = player:map(function(p) 
                    return `{p.name} (Level {p.level})`
                end),
                TextColor3 = player:map(function(p)
                    return p.appearance.theme == "dark" and Color3.new(1, 1, 1) or Color3.new(0, 0, 0)
                end)
            },
            Rex("TextLabel") {
                Text = player:map(function(p)
                    return `HP: {p.stats.health} | MP: {p.stats.mana}`
                end)
            }
        }
    }
end

Conditional Properties

Create conditional properties that change based on state:

local function ConditionalExample()
    local mode = Rex.useState("normal") -- "normal", "warning", "error"
    local isEnabled = Rex.useState(true)
    
    return Rex("TextButton") {
        Text = mode:map(function(m)
            if m == "error" then return "Error!"
            elseif m == "warning" then return "Warning!"
            else return "Normal"
            end
        end),
        
        -- Conditional styling based on mode
        BackgroundColor3 = mode:map(function(m)
            if m == "error" then return Color3.fromRGB(255, 100, 100)
            elseif m == "warning" then return Color3.fromRGB(255, 200, 100)
            else return Color3.fromRGB(100, 150, 255)
            end
        end),
        
        -- Conditional event handlers
        onClick = isEnabled:map(function(enabled)
            return enabled and function()
                print("Button clicked in", mode:get(), "mode")
            end or nil
        end),
        
        -- Conditional transparency
        BackgroundTransparency = isEnabled:map(function(enabled)
            return enabled and 0 or 0.5
        end)
    }
end

Animation-Ready Properties

Combine reactive properties with tweening for smooth animations:

local function AnimatedExample()
    local isExpanded = Rex.useState(false)
    local currentSize = Rex.useState(UDim2.fromOffset(100, 100))
    
    -- Animate size changes
    Rex.useEffect(function()
        local targetSize = isExpanded:get() and UDim2.fromOffset(200, 200) or UDim2.fromOffset(100, 100)
        
        -- Create tween
        local tweenInfo = TweenInfo.new(0.3, Enum.EasingStyle.Quart, Enum.EasingDirection.Out)
        
        -- Since we can't tween state directly, we'll tween a temporary object
        local proxy = { Size = currentSize:get() }
        local tween = game:GetService("TweenService"):Create(proxy, tweenInfo, { Size = targetSize })
        
        local connection = tween:GetPropertyChangedSignal("PlaybackState"):Connect(function()
            if tween.PlaybackState == Enum.PlaybackState.Playing then
                -- Update state during tween
                game:GetService("RunService").Heartbeat:Connect(function()
                    if tween.PlaybackState == Enum.PlaybackState.Playing then
                        currentSize:set(proxy.Size)
                    end
                end)
            end
        end)
        
        tween:Play()
        
        return function()
            connection:Disconnect()
            tween:Destroy()
        end
    end, {isExpanded})
    
    return Rex("TextButton") {
        Text = isExpanded:map(function(expanded) return expanded and "Collapse" or "Expand" end),
        Size = currentSize, -- Reactive animated size
        Position = UDim2.fromScale(0.5, 0.5),
        AnchorPoint = Vector2.new(0.5, 0.5),
        
        onClick = function()
            isExpanded:update(function(current) return not current end)
        end
    }
end

Performance Considerations

1. Avoid Expensive Transformations

-- Good: Simple transformation
local text = count:map(function(c) return tostring(c) end)

-- Less ideal: Expensive transformation
local text = count:map(function(c)
    -- Expensive operation on every change
    local result = ""
    for i = 1, c do
        result = result .. "expensive calculation"
    end
    return result
end)

2. Use Computed States for Complex Logic

-- Good: Use computed state for expensive calculations
local expensiveResult = Rex.useComputed(function()
    return performExpensiveCalculation(data:get())
end, {data})

local element = Rex("TextLabel") {
    Text = expensiveResult -- Simple binding to computed state
}

-- Less ideal: Expensive calculation in map
local element = Rex("TextLabel") {
    Text = data:map(function(d)
        return performExpensiveCalculation(d) -- Recalculated every time
    end)
}

3. Memoize Computed Properties

-- Use memoization for expensive computed properties
local expensiveProperty = Rex.useComputed(function()
    return heavyCalculation(input:get())
end, {input}, "expensivePropertyKey") -- Memoization key

Common Patterns

Theme System

local function ThemedComponent()
    local theme = Rex.useContext(ThemeContext)
    
    return Rex("Frame") {
        BackgroundColor3 = theme:map(function(t) return t.backgroundColor end),
        BorderColor3 = theme:map(function(t) return t.borderColor end),
        
        children = {
            Rex("TextLabel") {
                TextColor3 = theme:map(function(t) return t.textColor end),
                Font = theme:map(function(t) return t.font end)
            }
        }
    }
end

Responsive Design

local function ResponsiveComponent()
    local screenSize = Rex.useState(workspace.CurrentCamera.ViewportSize)
    
    -- Update screen size on resize
    Rex.useEffect(function()
        local connection = workspace.CurrentCamera:GetPropertyChangedSignal("ViewportSize"):Connect(function()
            screenSize:set(workspace.CurrentCamera.ViewportSize)
        end)
        return function() connection:Disconnect() end
    end, {})
    
    return Rex("Frame") {
        -- Responsive size based on screen size
        Size = screenSize:map(function(size)
            local scale = math.min(size.X / 1920, size.Y / 1080)
            return UDim2.fromOffset(400 * scale, 300 * scale)
        end),
        
        -- Responsive text size
        children = {
            Rex("TextLabel") {
                TextScaled = false,
                TextSize = screenSize:map(function(size)
                    return math.max(12, size.Y / 50) -- Minimum 12px, scales with height
                end)
            }
        }
    }
end

Data-Driven UI

local function DataDrivenList()
    local items = Rex.useState({
        {name = "Apple", color = Color3.fromRGB(255, 0, 0), price = 1.50},
        {name = "Banana", color = Color3.fromRGB(255, 255, 0), price = 0.75},
        {name = "Cherry", color = Color3.fromRGB(200, 0, 100), price = 2.00}
    })
    
    return Rex("ScrollingFrame") {
        children = {
            Rex("UIListLayout") {},
            items:map(function(itemList)
                local children = {}
                for i, item in ipairs(itemList) do
                    table.insert(children, Rex("Frame") {
                        key = item.name,
                        BackgroundColor3 = item.color,
                        Size = UDim2.new(1, 0, 0, 50),
                        children = {
                            Rex("TextLabel") {
                                Text = `{item.name} - ${item.price}`,
                                Size = UDim2.fromScale(1, 1),
                                BackgroundTransparency = 1
                            }
                        }
                    })
                end
                return children
            end)
        }
    }
end

Reactive properties are the heart of Rex’s declarative approach. By binding state directly to UI properties, you create dynamic, responsive interfaces that automatically stay in sync with your application’s data.