The site is under maintenance, so logging in is temporarily unavailable. All resources and the MCP still work.

AI Skills

Reactive UI with Fusion 0.3

By Roblox Vibecoders · roblox-fusion

Use when building or editing Roblox UI with Fusion 0.3 (elttob/fusion) - scopes, Value, Computed, New, Children, OnEvent, ForValues, Spring and Tween - or when porting Fusion code that still calls state:get() or Fusion.New without a scope.

Install it

Terminal
mkdir -p ~/.claude/skills/roblox-fusion && curl -fsSL https://robloxvibecoders.com/skills/roblox-fusion/SKILL.md -o ~/.claude/skills/roblox-fusion/SKILL.md

Skills in ~/.claude/skills work in every project. Put them in your project's .claude/skills folder to share them with your team.

What your AI reads

SKILL.md
---
name: roblox-fusion
description: Use when building or editing Roblox UI with Fusion 0.3 (elttob/fusion) - scopes, Value, Computed, New, Children, OnEvent, ForValues, Spring and Tween - or when porting Fusion code that still calls state:get() or Fusion.New without a scope.
---

# Reactive UI with Fusion 0.3

[Fusion](https://elttob.uk/Fusion/0.3/) builds Roblox UI from state. You keep values in state objects, describe
instances with `New`, and Fusion updates the properties when the state changes. This skill targets **Fusion 0.3**,
which is built around **scopes**. Code that calls `state:get()` or `Fusion.New "Frame"` without a scope is older:
see "Porting older Fusion code".

## Install (Wally)

```toml
[dependencies]
Fusion = "elttob/fusion@0.3.0"
```

## Scopes own everything

Every object Fusion makes is stored in a scope. `doCleanup(scope)` destroys its contents in reverse order of
creation. Forgetting to clean a scope is the main way Fusion UI leaks.

```luau
local Fusion = require(ReplicatedStorage.Packages.Fusion)
local scoped, peek = Fusion.scoped, Fusion.peek
local Children, OnEvent, OnChange, Out = Fusion.Children, Fusion.OnEvent, Fusion.OnChange, Fusion.Out

local scope = scoped(Fusion) -- every Fusion function becomes a method: scope:New, scope:Value...
-- build UI here
scope:doCleanup() -- same as Fusion.doCleanup(scope)
```

- `scoped(Fusion, { Button = Button })` also adds your components as methods. Two tables with the same key error.
- `scope:innerScope()` makes a child scope that is cleaned up with its parent, or earlier with `doCleanup`. Use it
  for a popup or tab that closes before the screen does.
- `scope:deriveScope()` makes a separate scope with the same methods.
- Anything else that needs cleanup can be added by hand: `table.insert(scope, connection)`. Scopes accept
  functions, instances, connections, objects with a destroy method, and other scopes.

## State

```luau
local coins = scope:Value(0)
coins:set(10)
print(peek(coins)) --> 10

local coinsText = scope:Computed(function(use, _)
	return `Coins: {use(coins)}`
end)
```

- Inside a `Computed`, read with `use(state)`: it reads and subscribes. Everywhere else (event handlers, one-off
  reads) use `peek(state)`, which never subscribes.
- `use` and `peek` also accept plain values, so components can take either a constant or a state object.
- The second callback argument is an inner scope that is cleaned up every time the computed recalculates. Create
  objects that belong to one result there, never in the outer scope.
- A computed must return immediately: no `task.wait`, no remote calls. It must not have side effects either,
  because Fusion may postpone or skip recalculating a computed nobody is using. For side effects use an observer:

```luau
local disconnect = scope:Observer(coins):onChange(function()
	playSound("Coin") -- runs on every change; onBind() also runs once right away
end)
```

## Instances, events and children

```luau
local isOpen = scope:Value(false)

scope:New "ScreenGui" {
	Name = "Shop",
	ResetOnSpawn = false,
	Parent = Players.LocalPlayer:WaitForChild("PlayerGui"),

	[Children] = {
		scope:New "TextButton" {
			Size = UDim2.fromOffset(160, 48),
			Text = scope:Computed(function(use)
				return if use(isOpen) then "Close" else "Shop"
			end),
			[OnEvent "Activated"] = function()
				isOpen:set(not peek(isOpen))
			end,
		},
		scope:New "Frame" {
			Visible = isOpen, -- a state object bound straight to a property
			Size = UDim2.fromScale(0.5, 0.6),
		},
	},
}
```

- `New` is curried: `scope:New("Frame")({ ... })`, usually written `scope:New "Frame" { ... }`.
- Property changes are applied on the next frame, not instantly after `:set()`.
- `[Children]` takes an instance, an array (nested arrays are fine), a state object holding either, or `nil`.
- `[OnChange "Text"] = function(newText) end` listens to a property; `[Out "AbsoluteSize"] = sizeValue` writes a
  property into a Value.
- `scope:Hydrate(existingInstance) { ... }` applies the same kind of property table to an instance that already
  exists, such as UI a designer built in Studio.

## Lists

`ForValues`, `ForPairs` and `ForKeys` turn a table (or a state object holding one) into a new table, one callback per
entry. Callbacks receive `use` and an inner `scope` first, so each row is cleaned up when its entry disappears.

```luau
local items = scope:Value({ "Sword", "Bow", "Staff" })

scope:New "ScrollingFrame" {
	Size = UDim2.fromScale(1, 1),
	[Children] = {
		scope:New "UIListLayout" {},
		scope:ForValues(items, function(use, scope, itemName)
			return scope:New "TextLabel" {
				Size = UDim2.new(1, 0, 0, 32),
				Text = itemName,
			}
		end),
	},
}
```

`ForPairs(t, function(use, scope, key, value) return newKey, newValue end)` maps keys and values;
`ForKeys(t, function(use, scope, key) return newKey end)` maps only keys. Always build rows with the callback's own
`scope` argument (it shadows the outer one on purpose).

## Animation

```luau
local hovering = scope:Value(false)
local goalSize = scope:Computed(function(use)
	return if use(hovering) then UDim2.fromOffset(176, 52) else UDim2.fromOffset(160, 48)
end)

scope:New "TextButton" {
	Size = scope:Spring(goalSize, 25, 0.6), -- (goal, speed, damping)
	BackgroundTransparency = scope:Tween(
		scope:Computed(function(use) return if use(hovering) then 0 else 0.2 end),
		TweenInfo.new(0.15, Enum.EasingStyle.Quad)
	),
	[OnEvent "MouseEnter"] = function() hovering:set(true) end,
	[OnEvent "MouseLeave"] = function() hovering:set(false) end,
}
```

## Components

A component is a function that takes the scope first, then a props table, and returns instances.

```luau
type UsedAs<T> = Fusion.UsedAs<T>

local function Button(scope: Fusion.Scope<typeof(Fusion)>, props: {
	Text: UsedAs<string>,
	LayoutOrder: number?,
	OnClick: () -> (),
})
	return scope:New "TextButton" {
		Size = UDim2.fromOffset(160, 48),
		Text = props.Text,
		LayoutOrder = props.LayoutOrder,
		[OnEvent "Activated"] = props.OnClick,
	}
end

local scope = scoped(Fusion, { Button = Button })
local buy = scope:Button { Text = "Buy", OnClick = function() buyRemote:FireServer("Sword") end }
```

- Keep components free of game lookups (`Players.LocalPlayer`, remotes, data). Pass values and callbacks in as
  props so the component can be previewed in a story. See the `roblox-ui-stories` skill.
- One file per reusable component, one screen per feature, one top-level scope per screen.

## Connecting game state

Bridge outside signals into a Value and give the connection to the scope:

```luau
local player = Players.LocalPlayer
local coins = scope:Value(player:GetAttribute("Coins") or 0)
table.insert(scope, player:GetAttributeChangedSignal("Coins"):Connect(function()
	coins:set(player:GetAttribute("Coins") or 0)
end))
```

The same works for Charm (write into the Value from a Charm effect and store its dispose function in the scope).
The UI only displays state and sends requests through remotes; the server decides.

## Porting older Fusion code

- `state:get()` is gone: use `use(state)` inside computeds and `peek(state)` everywhere else.
- Constructors need a scope: `Fusion.New "Frame" {}` becomes `scope:New "Frame" {}`, `Value(0)` becomes
  `scope:Value(0)`, and so on for `Computed`, `ForValues`, `Spring`, `Tween`, `Observer`, `Hydrate`.
- Computed and `For*` callbacks now start with `(use, scope, ...)`.
- Every screen gets a scope that is cleaned up when the screen goes away.

## Mistakes to avoid

- Creating objects with the outer scope inside a `Computed` or `ForValues` callback. They pile up on every
  recalculation. Use the callback's `scope` argument.
- Calling `peek` inside a computed and expecting it to update. Only `use` subscribes.
- Side effects (sounds, remotes, `print` for logic) inside computeds. Use `scope:Observer`.
- Never calling `doCleanup`. Destroying the ScreenGui by hand does not disconnect observers or springs.
- Reading a property right after `:set()` and expecting the instance to have changed in the same frame.
- Mixing Fusion 0.2 examples from old tutorials into 0.3 code.