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
Three modules, and that is the whole surface. Every signature below was read off the installed 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.
The package has been restructured into a monorepo using npm workspaces. The package name and API remain the same, but the repository layout has changed.