Signals hold state, computeds derive from them, and effects run when what they read changes. Dependencies are tracked as your code reads them, so a write re-runs only what actually depends on it. No runtime dependencies, and the types come from ReScript.
open Signals
let count = Signal.make(0)
let doubled = Computed.make(() =>
Signal.get(count) * 2
)
Effect.run(() => {
log(Signal.get(count))
None
})
Running
count→doubled→effect
count0
doubled0
Effect output
count is 0
One line per change, never two — the effect runs once after each write, not once per signal it reads.
Setup
Getting started
Three steps, and nothing to wire up by hand afterwards.
1
Install the package
npm install rescript-signals
2
Declare it in rescript.json
The ReScript compiler resolves dependencies from this file.
{
"dependencies": ["rescript-signals"]
}
3
Open Signals and build something
The package is namespaced, so open Signals brings Signal, Computed and Effect into scope.
open Signals
let query = Signal.make("")
let results = Computed.make(() => search(Signal.get(query)))
Effect.run(() => {
render(Signal.get(results))
None
})
// Re-runs the search and the render, once
Signal.set(query, "Jorge Ben")
Nothing subscribes by hand. The computed depends on the signal because it read it, the effect depends on the computed for the same reason, and writing to the signal is what re-runs them.
Using React?
rescript-signals-react adapts signals to components through useSyncExternalStore: useSignalValue to subscribe to a signal, useSignal for component-local state, useComputed for derived values.
open Signals
open SignalsReact
let count = Signal.make(0)
@react.component
let make = () => {
let value = useSignalValue(count)
<span> {React.string(Int.toString(value))} </span>
}
Reference
API
Signal, Computed and Effect make up the library. The signatures below are taken from the source.
A signal holds a value. Reading one inside a computed or an effect records a dependency, so a write re-runs exactly the things that read it — and nothing else.
let count = Signal.make(0)
let artist = Signal.make("Gal Costa")
// ~name labels the signal in debugging outputlet total = Signal.make(~name="total", 0)
// ~equals decides what counts as a changelet point = Signal.make(
~equals=((x1, y1), (x2, y2)) => x1 == x2 && y1 == y2,
(0, 0),
)
Without ~equals, values are compared by reference (===). Setting a signal to a value equal to the one it holds notifies nothing, so writing the same primitive twice costs nothing. Records and arrays are compared by identity, so pass ~equals when a structurally equal value should count as unchanged.
let count = Signal.make(5)
Signal.get(count) // 5// Inside a computed or an effect, this is what// creates the link back to countlet doubled = Computed.make(() => Signal.get(count) * 2)
// Reads the threshold, but does not re-run// when the threshold changes
Effect.run(() => {
let value = Signal.get(input)
let limit = Signal.peek(threshold)
Console.log(value > limit)
None
})
Groups writes so dependents run once at the end instead of once per write.
let firstName = Signal.make("Gal")
let lastName = Signal.make("Costa")
// One re-run, not two
Signal.batch(() => {
Signal.set(firstName, "Elis")
Signal.set(lastName, "Regina")
})
Creates a derived value. Dependencies are whatever fn reads.
let count = Signal.make(5)
let doubled = Computed.make(() => Signal.get(count) * 2)
// ~equals stops a downstream re-run when the derived// value comes out the samelet bucket = Computed.make(
~equals=(a, b) => a == b,
() => Signal.get(count) / 10,
)
A computed is a Signal.t, so it is read with the Signal functions.
let doubled = Computed.make(() => Signal.get(count) * 2)
Signal.get(doubled) // reads, and records a dependency
Signal.peek(doubled) // reads without recording one
There is no Computed.get or Computed.peek. Computed.make returns a Signal.t<'a>, which is what makes a computed usable anywhere a signal is — including as the source of another computed.
// Only needed for a computed that still has subscribers
Computed.dispose(doubled)
// Still usable — the next read rebuilds its dependencies
Signal.get(doubled)
Rarely needed. A computed attaches to its sources only while something is subscribed to it and detaches on its own once the last subscriber goes away, so a computed you create and drop needs no cleanup.
Cached
The result is held until a dependency changes. Reading a computed repeatedly costs one recomputation, not one per read.
Lazy
The computation runs when the value is read, not when a dependency is written.
Self-releasing
A computed attaches to its sources only while it has a subscriber. Deriving a value inline is safe: one you drop keeps nothing alive and costs its sources nothing on write.
Glitch-free
Computeds recompute in dependency order, so no observer sees a value derived from a half-applied update.
The return value is an optional cleanup, run before each re-run and on disposal.
Effect.run(() => {
let id = setInterval(tick, Signal.get(interval))
Some(() => clearInterval(id))
})
// Changing interval clears the old timer// before starting the new one
Signal.set(interval, 500)
Return None when there is nothing to undo. An effect that acquires something — a timer, a listener, a subscription — should return the release for it, or the acquisition leaks on every re-run.
Debug lets a tool observe the library: it reports nodes as they are created, the dependencies between them, writes and runs. Nothing is reported until a hook is installed.
Install before creating anything reactive. A node created earlier has no id and is never reported.
captureStacks records new Error().stack for every created node, so one without a ~name can still be traced to the code that made it. It costs a stack capture per node, so leave it off unless you need it.
type event =
| Created({id: int, kind: nodeKind, name: option<string>, stack: option<string>})
| Linked({source: int, target: int}) // target read source for the first time
| Unlinked({source: int, target: int}) // re-tracking dropped the dependency
| Written({id: int, changed: bool}) // Signal.set, after the value is stored; changed is false when equals held
| RunStart({id: int}) // a computed recomputes or an effect runs
| RunEnd({id: int, changed: bool, threw: bool})
| Disposed({id: int}) // after an Unlinked for each dependency it drops
| Collected({id: int}) // reclaimed by the garbage collector
| BatchStart // the outermost Signal.batch
| BatchEnd
type nodeKind = [#Signal | #Computed | #Effect]
Computeds are lazy, so one often recomputes inside the effect that reads it: its RunStart and RunEnd nest within the effect's.
RunEnd reports whether a computed's value changed (always true for an effect). A computed whose ~equals held reports changed: false, and nothing downstream hears about it.
A batch's events end after its flush, so one batch is the writes in it and everything they caused.
let alive = Debug.nodes()
Console.log(Array.length(alive))
nodes and inspect need WeakRef, which every current engine has. Without it they return nothing, and events still flow, rather than holding every node alive.
Reads one node: its value, its level, what it reads and what reads it.
type node = {
id: int,
kind: nodeKind,
name: option<string>,
level: int, // the scheduler's run order
value: option<Obj.t>, // None for an effect
sources: array<int>, // ids of the nodes this one reads
observers: array<int>, // ids of the nodes that read this one
dirty: bool,
disposed: bool,
}
switch Debug.inspect(id) {
| Some({name, sources}) => Console.log2(name, sources)
| None => () // collected, or created before install
}
inspect reads the live graph, so call it when you want a value, rather than expecting every write to carry one.
A page can hold several copies of the library, say an app and a component library built against separate ones. Installing into each instruments them all.
Check copy.version before relying on the shape: it is 1 today.
Cost
While no hook is installed, each instrumentation point is one boolean check, and nothing allocates.
Lifetime
Nodes are held weakly, so a tool never keeps one alive.
Behavior
Installing a hook does not change how signals, computeds or effects behave.
History
Changelog
The five most recent releases. This page documents 3.1.2, the version it was built against.