Skip to content

Reactivity

Lucy’s state is plain Luau. A ref is a function: call it to read, call it with a value to write. There is no .value and no unwrapping magic.

local count = Lucy.ref(0)
count()        -- 0
count(5)       -- write: everything that read count updates

Updates are fine-grained. When count changes, only the exact properties and effects that read it run again. Nothing re-renders, and nothing is diffed.

State

ref

local name = Lucy.ref("Lucy")
name("Goat")              -- write
name(nil)                 -- writing nil is fine
Lucy.trigger(list)        -- notify after mutating a ref's table in place

Writing the value a ref already holds does nothing.

computed

A cached, lazy derivation. It runs when first read, and again only when something it read has changed.

local double = Lucy.computed(function()
	return count() * 2
end)

Make it writable by passing get and set:

local fullName = Lucy.computed({
	get = function() return `{first()} {last()}` end,
	set = function(value)
		local a, b = string.match(value, "^(%S+) (%S+)$")
		first(a)
		last(b)
	end,
})
fullName("Goat Soft")

A computed that ends up with the same value stops the update there, so isAlive = computed(function() return hp() > 0 end) only notifies when it flips.

reactive

A deep, reactive view of a table, for state you would rather write as fields.

local player = Lucy.reactive({ coins = 0, bag = {} })
player.coins += 10                 -- only readers of `coins` update
Lucy.insert(player.bag, "sword")   -- lists: use Lucy.insert / Lucy.remove
for i, item in player.bag do end   -- generalized iteration and # are tracked

The table library works on raw tables, so table.insert(player.bag, x) would bypass tracking. Use Lucy.insert and Lucy.remove, or assign bag[#bag + 1] = x. Lucy.toRaw(proxy) returns the plain table. Frozen tables are left as they are.

App state as a ref

local coins = app.state:ref("player.coins")
coins(coins() + 10)    -- writes through app.state:set

It follows writes from anywhere, so the store and your bindings stay in step.

Effects

local stop = Lucy.watchEffect(function(onCleanup)
	print("coins", coins())
	onCleanup(function() end)   -- before the next run, and on stop
end)

Lucy.watch(count, function(new, old, onCleanup) end)                 -- lazy
Lucy.watch(count, callback, { immediate = true })                    -- run now too
Lucy.watch({ a, b }, function(values, previous) end)                 -- several sources
Lucy.watch(function() return player.coins end, callback)             -- a getter
Lucy.watch(player, callback)                                         -- deep, any change
  • Lucy.batch(fn) groups writes; dependents run once, afterwards.
  • Lucy.untrack(fn) and Lucy.peek(source) read without subscribing.
  • Lucy.isRef(x) is true for refs and computeds; Lucy.isWritable(x) is true for refs and computeds made with { get, set }.
  • Lucy.toValue(x) reads a ref, computed or getter, and passes plain values through.

An effect never re-triggers itself by writing a ref it reads. Effects that keep triggering each other are stopped after 100 rounds with an error, instead of freezing the game.

Lifetime

Everything created inside an effect, an element or a scope belongs to it, and is cleaned up when its owner re-runs or stops. You rarely need to think about it.

local scope = Lucy.effectScope()
scope:run(function()
	-- effects, computeds, elements...
	Lucy.onCleanup(function() end)
end)
scope:stop()   -- stops all of it

Declarative UI

Lucy.new creates an instance. Any property can be a ref, a computed or a getter.

local count = Lucy.ref(0)

Lucy.new "TextButton" {
	Size = UDim2.fromOffset(200, 40),
	Text = function() return `Clicked {count()}` end,
	Tags = { "card", ["card-active"] = isActive },
	Attributes = { Health = hp },
	[Lucy.on "Activated"] = function() count(count() + 1) end,
	Parent = gui,

	Lucy.new "UICorner" {},
}
KeyDoes
Property = valuesets it; a ref or getter keeps it bound
Tags = { "a", ["b"] = ref }static tags, plus tags toggled by a boolean; or a getter returning the whole set
Attributes = { Name = value }attributes, bound the same way
[Lucy.on "Event"] = fnconnects an event
[Lucy.changed "Prop"] = fncalls fn(newValue) when a property changes
[Lucy.model "Text"] = reftwo-way binding
[Lucy.out "AbsoluteSize"] = refthe ref follows the property
[Lucy.instance] = refthe instance, for later
array itemschildren: instances, component handles, arrays, show and each
Children = { Name = child }named children
Parent = guiset last

Children keep their declared order: Lucy assigns ascending LayoutOrder unless you set one.

Conditionals and lists

Lucy.new "Frame" {
	Lucy.show(isOpen, function()
		return Lucy.new "TextLabel" { Text = "Open" }
	end, function()
		return Lucy.new "TextLabel" { Text = "Closed" }   -- optional fallback
	end),

	Lucy.each(items, function(item, index)
		return Lucy.new "TextLabel" {
			Text = function() return `{index()}. {item.name}` end,
		}
	end, { key = function(item) return item.id end }),
}
  • show builds a branch when the condition flips, and destroys the old one.
  • each keeps the instances for items that stay and only moves them; new items are rendered and removed ones destroyed. Items are matched by key, or by the item itself.

Studio-built UI

Lucy.hydrate binds an existing instance, so designers can build in Studio and you only add behaviour:

local hud = playerGui.HUD
Lucy.hydrate(hud.Coins) { Text = function() return tostring(coins()) end }
Lucy.hydrate(hud.Shop) { [Lucy.on "Activated"] = openShop }

Hydrated instances are unbound when their owner stops, never destroyed.

Lifetime of instances

An element created inside an effect, a show branch, an each item or a scope is destroyed when that owner goes away. If something else destroys the instance, its bindings stop. You do not need to clean up by hand.

Components

Lucy’s components take refs, computeds and getters for any state key, and bind writable ones both ways: Lucy.Switch.new({ checked = music }). See Components.

Animation

Springs and tweens are reactive sources too, so you can bind them like anything else.

local open = Lucy.ref(false)
local x = Lucy.spring(function() return if open() then 0 else -320 end, 6, 0.8)

Lucy.new "Frame" {
	Position = function() return UDim2.fromOffset(x(), 0) end,
}

local fade = Lucy.tween(target, TweenInfo.new(0.25, Enum.EasingStyle.Quad))
  • spring(source, speed = 10, damping = 1): speed is how fast (natural frequency in Hz), damping below 1 bounces. Both can be refs.
  • tween(source, info): eases from where it is to each new target.
  • Animates number, Vector2, Vector3, UDim, UDim2 and Color3. Colours move through Oklab, so red to blue passes through a clean purple. Other types snap.
  • One Heartbeat connection drives every animation, and it disconnects when all of them are at rest.