pesde twistedsignal / ui

Reactive state

The reactive APIs run synchronously. An ordinary write finishes all resulting derived work, effects, and bindings before the write returns. ui.batch groups several writes into one synchronous propagation cycle; it does not wait for a frame, spawn a task, or change Roblox scheduling.

Which API should I use?

NeedAPI
Writable statevalue
Computed read-only statederive
Reactive Instance propertiesbinding function
Non-property side effectseffect
Group related state changesbatch
Read without trackinguntrack
Register owned cleanupcleanup

Values and derived values

ui.value(initial) returns writable state. Call it without arguments to read the current value and with one argument to write it. A write takes effect at once. If newValue == currentValue, the write does nothing and sends no notification.

ui.derive(compute) returns a read-only value. It records the values read by compute, caches the result, and recomputes when a recorded dependency changes. Dependencies are dynamic: every computation replaces the previous dependency set. A branch that is no longer read stops triggering the derived value.

local count = ui.value(0)
local doubled = ui.derive(function(): number
	return count() * 2
end)

Derived results do not use equality deduplication. A dependency change can notify consumers even when compute returns a result equal to its cached result. Use equality checks inside an effect if the external operation needs stricter deduplication.

ui.untrack(run) executes run without adding any reads to the current effect or derived computation. It returns the callback's result. It does not freeze a value or prevent later writes.

Batches

ui.batch(run) calls run immediately and returns all callback results. Writes inside it change their source values at once, so later reads in the same callback see the latest values. Reading a derived value also computes the current result, including every earlier write in the batch.

The following work waits until the outermost batch callback finishes:

  • public observable notifications from ui.value;
  • scheduled derived recomputation and derived observable notifications;
  • effect reruns and their cleanup;
  • reactive binding functions and their Instance property assignments.

Invalidation still crosses the entire dependency graph as each write happens. At the end, ui recomputes derived values in dependency order and runs effects after derived values settle. Each affected derived value, effect, or binding function runs at most once for the final state. This prevents diamond-shaped graphs from exposing a mix of old and new inputs.

local firstName = ui.value("")
local lastName = ui.value("")

local fullName = ui.derive(function()
	return `{firstName()} {lastName()}`
end)

ui.batch(function()
	firstName("Ada")
	lastName("Lovelace")
end)

Nested batches share their parent's propagation cycle. An empty batch has no observable effect. If the callback throws, ui finishes propagation for writes that already happened, restores batching state, then rethrows the original error with its traceback. Later writes continue normally.

Batch related writes that represent one logical change. A single ordinary write already propagates synchronously and does not need a batch. Do not use a batch to store computed state:

-- Prefer derive for this relationship.
local area = ui.derive(function(): number
	return width() * height()
end)

ui.batch only coordinates ui values and work reached through their reactive dependencies. An unrelated external Rx Observable can still emit while a batch callback is running according to that Observable's own scheduling rules.

Effects and cleanup

ui.effect(run) calls run immediately. It reruns synchronously when a value read by the previous execution changes. Like derive, it replaces its tracked dependency set after every run.

Before a rerun, ui disposes the previous execution's owned resources. The callback returned by the effect runs first. Callbacks registered with ui.cleanup then run in reverse registration order. Calling the function returned by effect performs the same cleanup and unsubscribes the effect; calling it again does nothing.

local stop = ui.effect(function(): () -> ()
	print(`Count is {count()}`)
	return function(): ()
		print("cleaning the previous run")
	end
end)

stop()

ui.cleanup(dispose) registers a resource with the current effect execution or tracked binding function. Calling it without an active reactive owner is an error. Reactive ownership is not general Roblox ownership. ui does not destroy an Instance that your code created or disconnect a connection that your code made unless you register that work with cleanup or return an effect cleanup.

Bindings

Use a binding function for a reactive Instance property:

local stop = ui.bind(label, {
	Text = function(): string
		return fullName()
	end,
})

The function is an effect with dynamic dependencies and follows the same batch and cleanup rules. bind owns the subscriptions, event connections, and reactive branches that it creates. Stopping the bind restores captured property values. It does not destroy the target Instance or any child Instance, and it does not own resources created outside its tracked functions. See Bindings for composition and property-layer rules.

Roblox adapters and Rx Observables

ui.attribute(instance, name, default) creates a two-way value for an attribute. ui.property(instance, name) does the same for a writable property. External Instance changes update reactive consumers, and writing the value updates the Instance.

ui.fromObservable(source) wraps an Rx Observable in a callable ui.ReadonlyValue<T>. Reading it returns the latest emitted value. The first read requires a synchronous emission; subscribe to .observable before reading an asynchronous source. ui.toObservable(input) converts a value, read-only value, tracked function, or constant into an Rx Observable.