Skip to content

Hydration

In traditional web development, "hydration" is the process of attaching event listeners and reactive state to static HTML that was already rendered by the server.

In Roblox development, you often build UI layouts visually in Roblox Studio. Instead of recreating these hierarchies entirely in code with Flux.new, you can use Flux.edit to "hydrate" existing instances. This applies reactive properties, event connections, and lifecycle management to pre-existing Instances.

The Flux.edit Function

Flux.edit is a curried function. Call it with an instance to receive a constructor function, then call that constructor with your properties table.

luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Flux = require(ReplicatedStorage.Flux)

local count = Flux(0)
local existingButton = script.Parent.CounterButton

-- Hydrate the static instance with reactive state and events
Flux.edit(existingButton) {
    -- Static properties are assigned immediately
    BackgroundColor3 = Color3.fromRGB(45, 45, 45),

    -- Reactive nodes update the property automatically
    Text = function()
        return "Count: " .. count
    end,

    -- Events are automatically connected
    Activated = function()
        count(count + 1)
    end,
}

NOTE

The ".." count concatenation reads the node's current value automatically; arithmetic and concatenation operators and string interpolation all do. Comparison operators are the exception; count > 10 errors, so read the value first with count(). See Signals.

Curried syntax reminder:

Flux.edit(instance) { props } is equivalent to Flux.edit(instance)({ props })

This is standard Luau syntax sugar for passing a table to a function, identical to how Flux.new works.

For lifecycle management, call Flux.edit inside a Flux.scope; the bindings it applies are owned by that scope and disconnected when it is destroyed:

luau
local scope = Flux.scope(function()
    Flux.edit(existingButton) {
        Text = someNode,
    }
end)

-- Later: disconnect every binding applied above
scope:Destroy()

Special Directives

To handle Roblox's unique instance architecture efficiently, Flux.edit supports several reserved keys within the properties table.

_ATTR (Attributes)

Roblox Attributes allow you to tag instances with custom metadata. Use the _ATTR table to set attributes or bind them to reactive nodes.

luau
Flux.edit(playerCharacter) {
    _ATTR = {
        IsStunned = true,            -- static assignment
        Health    = reactiveHealthNode, -- binds the attribute to update with the node
    },
}

_TAG (CollectionService Tags)

Use _TAG to manage CollectionService tags declaratively. It accepts:

  • A string, added as a tag once.
  • An array, where every element is itself a valid _TAG value (string, node, or function).
  • A node or function (implicit memo) whose value is a tag string or an array of tag strings. The instance's tags follow the value: when it changes, Flux diffs the new value against the old one, adds the newly listed tags, removes the dropped ones, and leaves tags present in both untouched (a kept tag never flickers off).
luau
local status = Flux("Idle")

Flux.edit(npcModel) {
    _TAG = {
        "Enemy",  -- static tag, applied once
        status,   -- reactive tag, swaps "Idle" for "Running" on status("Running")
    },
}

A reactive _TAG only removes tags it previously applied itself, so tags added by Studio or other scripts are left alone. Flux also reference-counts the tags it applies per instance, so a tag declared by more than one _TAG entry (say, a static string and a node that moves off the same value) stays on the instance until every declaring entry has dropped it. When the instance is destroyed (or the owning scope is wiped first), the listener disconnects and the currently applied tags remain on the instance.

_EVENT (Two-Way Binding & Listeners)

While standard RBXScriptSignals (like Activated or Touched) can be connected directly as top-level properties, the _EVENT table is used for two-way data binding and GetPropertyChangedSignal(property) listeners.

  • Assigning a reactive node to a property key makes the node receive the instance's property value whenever it changes (binding from the instance into the node).
  • Assigning a function to a property key checks the named member: if it is itself an RBXScriptSignal, the function is connected to that event directly; otherwise it falls back to GetPropertyChangedSignal(property).
  • Assigning a node or function inside _EVENT._ATTR does the same for attributes (a function connects to GetAttributeChangedSignal(attribute)).

TIP

To create a brand-new node mirroring a property without editing the instance, call Flux(instance, "Property") directly. See Binding from an Instance Property.

For a true round-trip, drive the property from the node at the top level and feed it back into the node inside _EVENT. The node then both updates the instance and tracks the user's edits:

luau
local textInputNode = Flux("")

Flux.edit(existingTextBox) {
    -- node -> instance: writing to the node updates the TextBox's Text
    Text = textInputNode,

    _EVENT = {
        -- instance -> node: updates 'textInputNode' whenever the TextBox's Text changes
        Text = textInputNode,

        -- This member is not a signal, so it falls back to GetPropertyChangedSignal
        BackgroundColor3 = function()
            print("Color changed!")
        end,

        -- Attribute change listeners
        _ATTR = {
            IsStunned = function(newValue)
                print("Stun state changed:", newValue)
            end,
        },
    },
}

_CLEAN (Lifecycle Cleanup)

Flux.edit automatically cleans up its own reactive bindings and event connections when the instance is destroyed. If you have additional external connections or objects that should be tied to the same lifecycle, add them to the _CLEAN table.

Everything in the table (connections, instances, functions, scopes, or nested arrays of these) is cleaned up when the instance's Destroying event fires. Flux copies the table internally, so your original table is never mutated. That also means items added to your table after the Flux.edit call are not tracked; register everything before editing, or use a Scope for late additions.

When a selector matches multiple instances, each match receives its own copy of the _CLEAN items, and they run once per destroyed match, so make cleanup functions idempotent if the selector can match more than one instance.

luau
local RunService = game:GetService("RunService")

local myConnections = {}
table.insert(myConnections, RunService.RenderStepped:Connect(function()
    -- Custom per-frame logic
end))

Flux.edit(existingFrame) {
    BackgroundColor3 = Color3.new(0, 0, 0),

    Activated = function()
        print("Clicked!")
    end,

    -- Flux will clean up everything in this table when the frame is destroyed
    _CLEAN = myConnections,
}

-- When existingFrame is destroyed:
-- 1. The 'Activated' connection is disconnected.
-- 2. The custom 'RenderStepped' connection is also safely disconnected.

NOTE

If you are editing inside a Scope, the _CLEAN table is also registered to that scope's master cleanup list, ensuring safe destruction even if the scope is wiped before the instance is manually destroyed.

_REF (Reference)

Sometimes you need a direct reference to the instance you are hydrating. Pass a callback function or a reactive node to _REF.

luau
local myFrameNode = Flux(nil)

Flux.edit(existingFrame) {
    -- Assigns the instance directly to the reactive node
    _REF = myFrameNode,

    -- OR use a callback:
    -- _REF = function(inst)
    --     print("Hydrated:", inst.Name)
    -- end,
}

A callback _REF may also return a cleanup value (a function, connection, instance, or array of them) which Flux ties to the instance's lifetime and runs on Destroying, exactly like _CLEAN. This lets a ref co-locate its own setup and teardown.

One-Off Directives

Every directive also has a function form that you place in the array portion of the property table (at a numeric index), like a Flux.Find selector. Use these to declare a single attribute, tag, or listener inline next to related code, or to splice directives in from a component fragment, without assembling the combined directive tables.

One-offEquivalent
Flux.attr(name, value) · Flux.attr { name = value }_ATTR = { name = value }
Flux.event(name, handler) · Flux.event { name = handler }_EVENT = { name = handler }
Flux.tag(...)_TAG = { ... }
Flux.ref(nodeOrCallback)_REF = nodeOrCallback
Flux.onDestroy(...)_CLEAN = { ... }

The values accepted are identical to the underlying directive: Flux.attr takes statics, nodes, functions, or Flux.model; Flux.tag takes any number of strings, nodes, or functions; Flux.event takes handler functions or bind-from nodes (and its map form supports the nested _ATTR table).

luau
Flux.edit(npcModel) {
    Flux.attr("Health", healthNode),
    Flux.attr("Kind", "NPC"),
    Flux.tag("Enemy", statusNode),
    Flux.event("Name", function(name)
        print("Renamed to", name)
    end),
    Flux.onDestroy(function()
        print("NPC removed")
    end),
}

You can repeat the same one-off as many times as you like: tags, events, and cleanup items accumulate, while a duplicated attribute name is applied once, with the root table's _ATTR entry winning over one-offs and earlier one-offs winning over later ones (the same first-wins rule as flattened arrays).

`Flux.onDestroy`{lua} vs `Flux.cleanup`{lua}

Flux.onDestroy(...) is the one-off form of _CLEAN, tying items to the instance's Destroying lifetime. The similarly named Flux.cleanup(fn) is unrelated: it registers a callback on the current reactive owner (see Scopes).

Array Elements (Children & Actions)

The array portion of the properties table (elements with numeric indices) serves two purposes.

1. Parenting Children

Any Roblox Instance placed at a numeric index will automatically have its Parent set to the edited instance.

NOTE

If the child is a GuiObject and its LayoutOrder is 0, Flux automatically assigns it to the element's numeric index, saving you from setting LayoutOrder on every child manually.

2. Deferred Actions

A function at a numeric index is deferred via task.defer and receives the hydrated instance as its argument. Use this for initialisation logic that should run after the current thread finishes applying properties.

luau
Flux.edit(existingContainer) {
    -- 1. This new instance will be parented to existingContainer
    Flux.new "TextLabel" { Text = "Child Label" },

    -- 2. This function is deferred; runs after all properties are applied
    function(inst)
        print("Finished hydrating " .. inst.Name)
    end,
}

Instance Selectors (Flux.Find)

When hydrating a complex, pre-built UI hierarchy, calling Flux.edit individually on every child creates verbose, hard-to-maintain code.

Flux.Find provides a set of Selectors for inline deep queries. Place a selector in the array portion of your Flux.edit table to find a descendant and apply a nested Flux.edit to it, all in one declarative block.

The properties passed to a selector are treated like a standard Flux.edit block; you can use reactive nodes, _EVENT, or nest selectors inside selectors.

Available Selectors

SelectorFinds
Find.Child(name)FindFirstChild(name)
Find.ChildClass(className)FindFirstChildOfClass(className)
Find.ChildIsA(className)FindFirstChildWhichIsA(className)
Find.Descendant(name)FindFirstDescendant(name)
Find.Ancestor(name)FindFirstAncestor(name)
Find.AncestorClass(className)FindFirstAncestorOfClass(className)
Find.AncestorIsA(className)FindFirstAncestorWhichIsA(className)
Find.Parent()Instance.Parent
Find.Query(query)QueryDescendants(query)
Find.QueryFirst(query)QueryDescendants(query)[1]

NOTE

Find.Parent curries like the other selectors; it ignores its query argument. Call it with empty parens and then apply the properties: Find.Parent() { ... }.

luau
local Find = Flux.Find
local existingMenu = script.Parent.SettingsMenu

local isMuted = Flux(false)

Flux.edit(existingMenu) {
    -- Top-level properties
    Visible = true,

    -- Selectors in the array portion
    Find.Child "CloseButton" {
        Activated = function()
            existingMenu.Visible = false
        end,
    },

    Find.Descendant "MuteToggle" {
        BackgroundColor3 = function()
            return isMuted() and Color3.new(1, 0, 0) or Color3.new(0, 1, 0)
        end,

        Activated = function()
            isMuted(not isMuted())
        end,
    },

    Find.QueryFirst "UIListLayout" {
        Padding = UDim.new(0, 10),
    },
}

Memory Management

Flux.edit connects to the instance's Destroying event. When the instance is destroyed, Flux disconnects all event connections, severs reactive bindings, and clears associated memory. No manual cleanup is required.

If you need to clean up before the instance is destroyed (e.g., when a scope is wiped), call Flux.edit(instance) { ... } inside a Flux.scope. The bindings are owned by that scope and are disconnected when it is destroyed.