#From React

For React Lua, @jsdotlua/react, or React through roblox-ts. The mapping is close to Roact with hooks in place of class methods, so read that page too if you used both.

The shift is the same one: React re-runs your component and diffs the result. Vision runs your function once, then writes properties from callbacks.

#The table

React Vision
createElement("Frame", props, ...children) { ClassName = "Frame", ...props, ...children }
a function component a function returning a declaration table
useState event("Name", Initial, Callback)
the setter from useState Interface.Name(Value)
useMemo over state derive("Name", Compute)
useEffect(fn, {}) ready(function(self, vision, later) end)
the function useEffect returns later(function(self, vision) end) inside ready
useEffect(fn, { value }) merge("Value", fn)
useRef on a host node Interface:Open()
useCallback nothing, functions are not recreated
useContext no equivalent, see below
createRoot(target) and root:render(el) Scope:Capture(Declaration) then Interface:Mount()
root:unmount() Interface:Cleanup()
createPortal(el, target) mount(Target) on the node
Fragment the array part of a declaration
key no equivalent, you hold the handle

#A counter

React:

local function Counter()
    local Count, SetCount = React.useState(0)

    return React.createElement("TextButton", {
        Text = `Clicks: {Count}`,
        [React.Event.Activated] = function()
            SetCount(Count + 1)
        end,
    })
end

local Root = ReactRoblox.createRoot(PlayerGui)
Root:render(React.createElement(Counter))

Vision:

local Counter = Scope:Capture({
    ClassName = "TextButton",

    mount(PlayerGui),

    event("Count", 0, function(self, _, Value)
        self.Text = `Clicks: {Value}`
    end),

    Activated = function(_, Panel)
        Panel.Count(Panel.Count() + 1)
    end,
})

Counter:Mount()

#Effects

ready runs once the tree is built and the root is parented, so measurements like AbsoluteSize are real by then. It hands you a registrar as its third argument for the teardown half:

ready(function(self, _, later)
    local Connection = Workspace.ChildAdded:Connect(Handle)

    later(function()
        Connection:Disconnect()
    end)
end)

That is useEffect with an empty dependency array. For the version with dependencies, bind to the value instead - merge("Name", fn) runs fn every time Name changes, and only then.

#Things that will trip you

Your component body is not a render function. It runs once, when you build the table. Anything that has to happen again belongs in a callback bound to a value.

There are no stale closures. The classic bug where a callback captures an old Count cannot happen here. Callbacks are handed the current value as an argument and the vision as the second, so Panel.Count() always reads what is there now.

Hooks rules do not apply. No ordering constraints, no dependency arrays, no exhaustive-deps lint. event and merge are markers in a table.

No context. The usual answers are to pass the vision into the functions that build your child declarations, or to use inject and receive when a child needs to find a parent it was not declared inside.

Lists are manual. There is no key and nothing diffs. Build a row vision, :Clone() it per item, write its values, parent :Open(), and keep the handles so you can :Cleanup() the ones that leave.