maneetoo/luau_signals

Luau Port of alien-signals 👾 the lightest signal library

luau-signals

Luau Port of alien-signals 👾 the lightest signal library

Get On Wally Get On Pesde Documentation GitHub Releases Roblox OSS Discord Server Changelog My Works

About

luau-signals is a faithful Luau port of alien-signals, a push-pull reactive signals library whose core algorithm has been adopted by Vue 3.6. It brings the same signal / computed / effect / effectScope primitives to Luau 🦭 and Roblox, with the same lazy, glitch-free semantics: signals hold state, computed values are derived lazily, effects react to changes, and scopes let you dispose whole component trees at once.

The port preserves the upstream constraints that make alien-signals fast. Writes push flags through the graph while reads pull fresh values only when needed, so an unobserved computed chain is never touched. The hot path uses linked lists and plain arrays only — no Set, no Map, no dynamic fields — and propagate, checkDirty, and shallowPropagate are iterative rather than recursive. Each Link is seven flat fields with no hidden allocation.

What is specific to Luau is mostly adaptation. Signals, computed values, and effects are callable tables — the node is the API — so no closure or wrapper is allocated per node. Node kinds are identified by metatable identity instead of 'getter' in node checks, which cannot be ported literally because a nil-valued field does not exist in Luau; this also makes signal(nil) unambiguous. All flag operations go through bit32 (Luau has no native &, |, ~ for numbers), with masks precomputed and bit32.btest used for "any of these bits set". Every finally block from the original is reproduced with pcall plus a re-error, including state restoration on thrown errors, and the linked-list stack is replaced with a plain array plus a depth counter for cheaper push/pop and safe reentrancy.

Getting Started

Please follow the documentation here!

Benchmarks

Currently, Vide is the most popular and frequently chosen reactivity library for Luau, thanks to its speed and an API largely derived from Solid.js - which is why it was selected for comparison with luau-signals. Furthermore, unlike Fusion, Vide is currently being actively maintained and updated.

ScenariosignalsvideWinnerRupture
batch: 10 writes, batched1.86 µs1.89 µs≈ equal~1.0×
batch: 10 writes, no batch10.2 µs12.0 µs🟢 signals1.17×
derive: 1 src → 50 derives → 1 effect12.4 µs17.3 µs🟢 signals1.39×
derive: chain depth 102.36 µs2.53 µs🟢 signals1.07×
derive: chain depth 10 → effect2.41 µs2.60 µs🟢 signals1.08×
derive: chain depth 407.79 µs9.50 µs🟢 signals1.22×
derive: cutoff (value unchanged)529 ns308 ns🔴 vide1.72×*
derive: diamond → effect1.17 µs1.11 µs≈ equal~1.0×
derive: unobserved (lazy vs eager)137 ns2.44 µs🟢 signals17.83×
derive: write + read (depth 1)377 ns371 ns≈ equal~1.0×
effect: create + dispose1.14 µs1.11 µs≈ equal~1.0×
effect: dynamic deps (branch toggle)681 ns421 ns🔴 vide1.62×*
effect: rerun with cleanup465 ns453 ns≈ equal~1.0×
graph: 20 src + 20 derive18.8 µs21.2 µs🟢 signals1.12×
mixed: 30 src, 60 derive, 15 effects2.68 µs9.90 µs🟢 signals3.69×
source: create110 ns106 ns≈ equal~1.0×
source: read (untracked)37 ns56 ns🟢 signals1.52×
source: write, no subscribers46 ns81 ns🟢 signals1.76×
source: write, same value37 ns39 ns≈ equal~1.0×
write → 1 effect419 ns317 ns🔴 vide1.32×*
write → 10 effects2.21 µs2.62 µs🟢 signals1.18×
write → 100 effects18.9 µs25.2 µs🟢 signals1.34×

* - Scenarios where Vide excels - essentially its "natural habitat" - are those involving a single action (e.g. write operation resulting in one effect) or situations where nothing changes following an update. In such cases, its "eager" update model incurs lower computational overhead. For larger-scale tasks, luau-signals is a better choice.

Code Sample

const luau_signals = require './luau-signals'
const signal, computed, effect, effectScope, startBatch, endBatch =
    luau_signals.signal, luau_signals.computed, luau_signals.effect, luau_signals.effectScope, luau_signals.startBatch, luau_signals.endBatch

const price, qty = signal(100), signal(2)
const total = computed(function()
    return price() * qty()
end)

-- effectScope — groups effects for collective disposal
const dispose = effectScope(function()
    -- effect — reacts to changes
    effect(function()
        print(`Total: { total() }`)
    end)
end) -- Output: `Total: 200`

price(150) -- Output: `Total: 300`

-- batch — multiple changes → a single effect restart
startBatch()
price(200)
qty(5)
endBatch() -- Output: `Total: 1000`

dispose() -- Unsubscribing from the entire scope
price(999) -- Nothing is being output

Attribution

The core algorithm is by Johnson Chu (alien-signals, MIT).

luau-signals is licensed under MIT License