signal
signal(initialValue: any?) -> SignalCreates a reactive value. The returned node is the API — a callable table:
local count = signal(0)
count() -- read → the current value
count(5) -- write → set the value, propagate to subscribers
count(nil) -- write → set the value to nil (still a write!)Reads and writes are distinguished by argument count. select('#', ...) is used internally, so an explicit nil argument is a write of nil, not a read.
Signature
| Call | Meaning |
|---|---|
signal(initialValue) | Create a node with the initial value. The argument is optional — signal() creates a signal whose value is nil. |
s() | Read the current value. Subscribes the active effect/getter, if one is running. |
s(value) | Write. If the new value ~= the pending one, marks the node dirty and propagates. |
Semantics
Creation
The node is allocated with all fields written up front (currentValue, pendingValue, and the five graph fields), the frozen SIGNAL_META metatable is attached, and the node is returned. Nothing else runs — no effects, no propagation.
Reading
- If the signal is dirty (a write happened since the last read), the pending value is committed first, and subscribers are shallow-propagated so dependents settle.
- If a subscriber is active (an effect callback or a getter is running), the read links the signal to it — this is how tracking happens.
- Returns the committed
currentValue.
A read outside any effect/getter is a plain value read with no side effects beyond a possible lazy commit.
Writing
- The value is stored into
pendingValue;currentValueis committed on the next read (lazy commits). - If the new value is
==to the old pending value, nothing propagates — same-value writes are no-ops for the graph. - Otherwise the node is marked
Mutable|Dirty,propagatewalks its subscriber list, and — ifbatchDepth == 0— the flush runs the queued effects immediately. Inside a batch, the flush is deferred toendBatch. - A write returns nothing (
nil).
Comparison
Equality is Luau == (which is strict, like === in JS): numbers, strings, booleans, nil compare by value; tables, functions, and instances compare by identity.
WARNING
Writing a freshly-built table each time (s({pos = x})) is a new value on every write even when contents match — every write will propagate. Store scalars, or reuse the same table when the content has not changed.
Trackability
A signal read inside a running effect, computed getter, or scope function links the signal to the reader. See Signals & Effects for the tracking model, and Advanced: setActiveSub for untracked reads.
Interaction with effects — the flow
Type guard
isSignal(value: any) -> booleanTrue exactly when value is a table whose metatable is the signal metatable. Non-tables (including strings, which share a metatable in Luau) return false rather than erroring. See Type Guards.
Examples
A store
local coins = signal(0)
local function grant(amount: number)
coins(coins() + amount)
end
effect(function()
print(`coins: {coins()}`)
end)Signals holding nil
local selected = signal(nil :: Instance?)
effect(function()
const target = selected()
if target == nil then
print("nothing selected")
else
print(`selected {target.Name}`)
end
end)
selected(workspace.Baseplate) -- "selected Baseplate"TIP
nil-holding signals are fully supported: the node kind is determined by metatable identity, not by which fields happen to be non-nil.
Errors
The signal itself does not throw in normal use. If an effect reached by the write throws during the flush, the error propagates from the write site after the queue is drained and reset — the library never leaves the queue half-consumed.
Upstream
Ported from signal in alien-signals src/index.ts. The bound signalOper function became a callable table; the 'currentValue' in node kind check became metatable identity.