Skip to content

UI Frameworks

Scribe’s client accessors already have the shape a reactive UI framework wants: a value you can read, and a subscription that tells you when it moved. Bridging one takes about five lines, and the repo ships those five lines for Vide, React and Fusion so you do not have to write them.

They live in addons/ui/. Wally installs src only, so copy the file you want into your game and name it whatever suits your project, or insert ScribeUIAdapters-Addon.rbxm from the release page, a folder holding all three. The snippets below call the modules ScribeVide, ScribeReact and ScribeFusion to keep them distinct from the framework itself. Each one takes your framework as an argument, because the framework sits at a path in your project that Scribe cannot know:

local useScribe = require(ReplicatedStorage.Shared.ScribeVide)(vide)
local coins = useScribe(Data.Coins)

The adapters are separate optional packages: @rbxts/scribe-react, @rbxts/scribe-vide, and @rbxts/scribe-fusion. Install only the adapter and framework your game uses. The core package includes none of these dependencies. See roblox-ts for publication status and all three installation examples.

import React from "@rbxts/react";
import ScribeReact from "@rbxts/scribe-react";
import { Data } from "shared/data";
const { useScribeBinding } = ScribeReact(React);
function CoinLabel() {
const coins = useScribeBinding(Data.Client.Coins); // React.Binding<number>
return React.createElement("TextLabel", {
Text: coins.map((value) => `${value} coins`),
});
}

React hooks retain the accessor’s value type, including undefined for an absent entry. Vide returns a typed source and disconnect through a LuaTuple, so use const [value, disconnect] = useScribe(accessor). Fusion returns a Value and disconnect in the same way. Its scoped 0.3 overload accepts your typed framework implementation and preserves its additional Value members; it does not require installing the 0.2 framework. Cleanup behavior is the same as in the Luau examples.

local useScribe = require(path.to.ScribeVide)(vide)
local function CoinLabel()
local coins, disconnect = useScribe(Data.Coins)
vide.cleanup(disconnect)
return vide.create("TextLabel")({
Text = function()
return `{coins()} coins`
end,
})
end
local ScribeReact = require(path.to.ScribeReact)(React)
local function CoinLabel()
local coins = ScribeReact.useScribe(Data.Coins)
return React.createElement("TextLabel", { Text = `{coins} coins` })
end

useScribe re-renders the component on every change, which is what you want when the value decides what is rendered. When it only feeds a property, useScribeBinding updates that property without re-rendering at all:

local coins = ScribeReact.useScribeBinding(Data.Coins)
return React.createElement("TextLabel", {
Text = coins:map(function(c)
return `{c} coins`
end),
})

It is built on useState, useEffect and useBinding, the three hooks jsdotlua React 17.2.1 exports. It deliberately avoids useSyncExternalStore, which is the React 18 hook for this job and does not exist in the Roblox port.

local useScribe = require(path.to.ScribeFusion)(Fusion, scope) -- 0.3, which takes a scope
local useScribe = require(path.to.ScribeFusion)(Fusion) -- 0.2, which does not
local coins, disconnect = useScribe(Data.Coins)

On 0.3 the disconnect is registered in the scope as well as returned, so doCleanup(scope) stops the Scribe listener with everything else, and calling the returned one early is safe. On 0.2 there is no scope, so call it yourself when the UI goes away.

Three properties of the client mirror do the work. Each is measured and pinned by a spec, so an adapter already pasted into your game keeps working.

Get() is referentially stable. Two calls with nothing changing in between return the same table, for scalars, containers and records alike. A fresh table per call would make a React memo or a Vide derived recompute forever, and it stays invisible until a UI is built on it.

That survives a resync. A dropped frame makes the client re-handshake and rebuild its mirror from defaults. The reference is unchanged across that when the value is, so a hiccup costs no spurious re-render and fires no spurious listener.

One frame of writes is one notification. Three Increment calls inside a Data.Batch reach the client as a single Changed. No adapter needs to debounce.

Observe rather than Changed, and why it matters here

The Vide and Fusion adapters subscribe with Observe, not Changed. Observe delivers the current value before it returns, which closes the gap between reading the initial value and subscribing to later ones. With Changed a write landing in that gap is lost and the UI sits on a stale first value.

The React adapter cannot use it, because a hook must return a value during render and the subscription only happens later in an effect. It re-reads with Get() at the top of the effect instead, which closes the same gap.

Another player’s Scribe.Shared roots have no accessor, so they cannot go through these adapters at all. Data.GetShared returns a plain table and Data.OnSharedChanged is the notification:

Data.OnSharedChanged:Connect(function(userId, sharedData)
local pets = sharedData and sharedData.EquippedPets
-- sharedData is nil when that player leaves
end)

Data.GetShared(localPlayer) is always nil, and permanently so rather than pending: the server broadcasts a player’s Shared data to everyone except that player. Read your own through the ordinary accessor, which the adapters handle.