---
name: roblox-ui-libraries
description: Use when choosing a declarative UI library for a Roblox game, or when writing UI with Vide (centau/vide) or React Lua (jsdotlua/react, the successor of Roact). Covers when to pick Fusion, Vide or React, each library's core API, mounting, lists, and cleanup.
---

# Choosing Fusion, Vide or React

Code-built UI in Roblox is usually written with one of three declarative libraries. All three describe instances from
state and update them for you. Pick one per project and stick to it.

## Which one

| | Fusion 0.3 | Vide | React Lua |
|---|---|---|---|
| Model | State objects in scopes | Sources (functions) and scopes, fine-grained like Solid | Components re-render, virtual tree, hooks |
| Reads | `use(x)` / `peek(x)` | `x()` | plain values from `useState` |
| Cleanup | `doCleanup(scope)` | destroy function from `root`/`mount` | `root:unmount()` |
| Best for | Teams that want structure and docs | Small API, fast updates, pairs with Charm (`vide-charm`) | Developers who know React, large component trees |

Rules for an agent:

1. **Use what the project already uses.** Check `wally.toml` and existing UI code before adding anything.
2. New project, no preference: Fusion 0.3 or Vide. Use React Lua when the team already writes React.
3. Never mix two of them in one screen. Never add Roact (legacy, replaced by React Lua) to new code.

For Fusion 0.3, load the `roblox-fusion` skill. The rest of this skill covers Vide and React Lua.

## Vide

```toml
[dependencies]
Vide = "centau/vide@0.4.1"
```

### Core API

```luau
local vide = require(ReplicatedStorage.Packages.Vide)
local create, source, derive, effect, cleanup = vide.create, vide.source, vide.derive, vide.effect, vide.cleanup

local count = source(0)          -- call with no args to read, one arg to write
count(count() + 1)

local doubled = derive(function() -- cached, recomputes when a source it read changes
	return count() * 2
end)

effect(function()                 -- runs now and again on change (must be inside a scope)
	print(doubled())
end)
```

`create "Class" { ... }` builds an instance:

- String key with a plain value: sets the property.
- String key with a function: connects the event if the key is an event, otherwise makes an effect that keeps the
  property updated (`Text = function() return ... end`).
- Number key: a child instance, an array of them, or a source that returns them.

```luau
local function Counter(props: { start: number })
	local count = source(props.start)
	return create "TextButton" {
		Size = UDim2.fromOffset(200, 50),
		Text = function()
			return `Clicked {count()} times`
		end,
		Activated = function()
			count(count() + 1)
		end,
		create "UICorner" {},
	}
end

local function App()
	return create "ScreenGui" {
		Name = "App",
		ResetOnSpawn = false,
		Counter { start = 0 },
	}
end

local destroy = vide.mount(App, Players.LocalPlayer:WaitForChild("PlayerGui"))
-- destroy() removes the UI and every effect created under it
```

In 0.4.1 only `create(className)(props)` is supported; the other `create` overloads are deprecated.

### Scopes and cleanup

- `root(fn)` and `mount(component, target)` create a stable scope and return a destroy function. Every effect must be
  created inside one, or Vide errors. Build the whole UI under one top-level `mount`.
- `cleanup(x)` runs when the current scope reruns or is destroyed. It accepts a function, a connection, an instance,
  a thread, or an object with a destroy/disconnect method.

```luau
local function CoinsLabel()
	local player = Players.LocalPlayer
	local coins = source(player:GetAttribute("Coins") or 0)
	cleanup(player:GetAttributeChangedSignal("Coins"):Connect(function()
		coins(player:GetAttribute("Coins") or 0)
	end))
	return create "TextLabel" {
		Text = function() return `Coins: {coins()}` end,
	}
end
```

### Conditional UI and lists

```luau
local shopOpen = source(false)
local items = source({} :: { { name: string } })

create "Frame" {
	vide.show(shopOpen, function() -- built while truthy, destroyed when falsy
		return ShopWindow {}
	end),
	create "ScrollingFrame" {
		create "UIListLayout" {},
		vide.indexes(items, function(item, index) -- one row per index; item is a source
			return create "TextLabel" {
				Text = function() return `{index}. {item().name}` end,
			}
		end),
	},
}
```

- `switch(source) { [value] = function() ... end }` picks one component by value.
- `values(source, fn)` makes one row per value (the index is a source). Use it for sets of unique objects that get
  reordered; use `indexes` for everything else.
- `spring(source, period, dampingRatio)` returns an animated source (numbers, UDim2, Color3, Vector3 and more).
- `untrack(fn)` reads without subscribing, `batch(fn)` groups writes so effects run once.
- Strict mode (`vide.strict`) turns on automatically unless the code runs at optimization level O2 (Roblox
  production builds use O2 by default). It reruns reactive scopes to catch impure code and errors when code yields where it should not.
  Never yield inside `derive`, `effect` or property functions.

## React Lua

```toml
[dependencies]
React = "jsdotlua/react@17.2.1"
ReactRoblox = "jsdotlua/react-roblox@17.2.1"
```

Keep both packages on the same version. React Lua follows React 17: React's own docs apply, except where noted here.

```luau
local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.Packages.ReactRoblox)
local e = React.createElement

local function Counter(props: { start: number })
	local count, setCount = React.useState(props.start) -- two return values, not an array
	return e("TextButton", {
		Size = UDim2.fromOffset(200, 50),
		Text = `Clicked {count} times`,
		[React.Event.Activated] = function()
			setCount(count + 1)
		end,
	}, {
		Corner = e("UICorner"),
	})
end

local screenGui = Instance.new("ScreenGui")
screenGui.ResetOnSpawn = false
screenGui.Parent = Players.LocalPlayer:WaitForChild("PlayerGui")

local root = ReactRoblox.createRoot(screenGui)
root:render(e(Counter, { start = 0 }))
-- root:unmount() when the UI goes away
```

### Roblox-specific parts

- Events: `[React.Event.MouseButton1Click] = function(rbx, x, y) end`. Property changes:
  `[React.Change.AbsoluteSize] = function(rbx) end`. Connections are made and broken for you.
- Children can be a map (`{ Title = e(...), Body = e(...) }`) or elements with a `key` prop. Never both at once.
- Refs: `local ref = React.useRef(nil)` then `ref = ref` in props; `ref.current` is the instance.
- Bindings update a property without re-rendering. Use them for values that change every frame:

```luau
local progress, setProgress = React.useBinding(0)
-- in a loop or tween callback: setProgress(alpha)
e("Frame", {
	Size = progress:map(function(alpha) return UDim2.fromScale(alpha, 1) end),
})
```

- Side effects go in `React.useEffect(function() ... return cleanup end, { deps })`, for example connecting to a
  signal and disconnecting in the returned function.
- `ReactRoblox.createRoot` renders asynchronously; `createLegacyRoot` renders synchronously. Don't read the instance
  tree right after `root:render`.
- Set `_G.__DEV__ = true` before requiring React in Studio and storybooks for warnings. Never ship it on.

## Mistakes to avoid

- Mixing libraries, or copying Roact (`Roact.mount`, `[Roact.Ref]`) or old Fusion examples into a new project.
- Vide: creating effects outside `root`/`mount`; yielding inside reactive functions; using `values` with duplicate
  entries.
- React: calling hooks inside `if` or loops; putting per-frame values in `useState` (use bindings); forgetting
  `root:unmount()`; leaving `__DEV__` on in production.
- Any library: components that reach into `Players.LocalPlayer` or remotes directly. Pass data and callbacks as props
  so the component can be previewed in a story (see `roblox-ui-stories`).
