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)roblox-ts
Section titled “roblox-ts”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, })endlocal ScribeReact = require(path.to.ScribeReact)(React)
local function CoinLabel() local coins = ScribeReact.useScribe(Data.Coins) return React.createElement("TextLabel", { Text = `{coins} coins` })enduseScribe 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.
Fusion
Section titled “Fusion”local useScribe = require(path.to.ScribeFusion)(Fusion, scope) -- 0.3, which takes a scopelocal 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.
Why they are this short
Section titled “Why they are this short”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.
What they cannot do
Section titled “What they cannot do”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 leavesend)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.
Where to next
Section titled “Where to next”- Reading & Writing Values covers
Get,Changed,ObserveandBatch. - Replication & Visibility decides which fields reach the client at all.
- Commands & Requests is how a button asks the server to change something.