Skip to content

Reactive signals for ReScript

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.

npm install rescript-signals
Source
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
  1. count is 0

One line per change, never two — the effect runs once after each write, not once per signal it reads.

Getting started

Three steps, and nothing to wire up by hand afterwards.

  1. 1

    Install the package

    npm install rescript-signals
  2. 2

    Declare it in rescript.json

    The ReScript compiler resolves dependencies from this file.

    {
      "dependencies": ["rescript-signals"]
    }
  3. 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>
}

API

Signal, Computed and Effect make up the library. The signatures below are taken from the source.

Signal#

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.

Signal.make(value, ~name=?, ~equals=?): Signal.t<'a>#

Creates a signal with an initial value.

let count = Signal.make(0)
let artist = Signal.make("Gal Costa")

// ~name labels the signal in debugging output
let total = Signal.make(~name="total", 0)

// ~equals decides what counts as a change
let 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.

Signal.get(signal): 'a#

Reads the value and records a dependency on it.

let count = Signal.make(5)

Signal.get(count) // 5

// Inside a computed or an effect, this is what
// creates the link back to count
let doubled = Computed.make(() => Signal.get(count) * 2)

Signal.peek(signal): 'a#

Reads the value without recording a dependency.

// 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
})

Signal.set(signal, value): unit#

Writes a new value.

Signal.set(count, 10)

Signal.update(signal, fn): unit#

Writes a new value derived from the current one.

Signal.update(count, n => n + 1)
Signal.update(lineup, artists => Array.concat(artists, ["Tim Maia"]))

Signal.batch(fn)#

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")
})

Batches nest: only the outermost one flushes.

Signal.untrack(fn): 'a#

Runs a block without recording any of the dependencies it reads.

// Depends on a, but not on b
let sum = Computed.make(() => {
  let a = Signal.get(source)
  let b = Signal.untrack(() => Signal.get(other))
  a + b
})

Signal.peek does this for a single read; untrack does it for a whole block, including reads inside functions it calls.

Computed#

A computed derives a value from other signals. It recomputes only when one of them changes, and only when something asks for the result.

Computed.make(fn, ~name=?, ~equals=?): Signal.t<'a>#

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 same
let bucket = Computed.make(
  ~equals=(a, b) => a == b,
  () => Signal.get(count) / 10,
)

Signal.get(computed) / Signal.peek(computed)#

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.

Computed.dispose(computed): unit#

Detaches a computed from its sources.

// 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.

Effect#

An effect runs a function now, and again whenever a signal it read has changed. It is where reactive state meets the world outside it.

Effect.run(fn, ~name=?): unit#

Runs fn immediately, then again on every change to what it read.

let count = Signal.make(0)

Effect.run(() => {
  Console.log(Signal.get(count))
  None
})
// logs 0

Signal.set(count, 1)
// logs 1

() => option<unit => unit>#

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.

Effect.runWithDisposer(fn, ~name=?): disposer#

Same as Effect.run, but returns a handle that stops the effect.

let disposer = Effect.runWithDisposer(() => {
  Console.log(Signal.get(count))
  Some(() => Console.log("cleaned up"))
})

disposer.dispose()
// logs "cleaned up"; later writes to count do nothing

type disposer = {dispose: unit => unit}. Disposing twice is safe.

For building devtools on top of the library. Apps don't need this.

Debug#

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.

Debug.install({emit, captureStacks}): unit#

Starts reporting events to emit.

Debug.install({
  captureStacks: false,
  emit: event =>
    switch event {
    | Created({id, kind, name}) => Console.log3("created", id, name)
    | Written({id, changed}) => Console.log3("write", id, changed)
    | RunStart({id}) => Console.log2("run", id)
    | _ => ()
    },
})

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.

Installing again replaces the hook.

type event#

What a hook receives, in the order it happens.

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.

Debug.uninstall(): unit#

Stops reporting and forgets every id.

Debug.uninstall()

Ids belong to one install: after uninstalling, the same node gets a new id the next time.

Debug.nodes(): array<int>#

The ids of every node still alive, oldest first.

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.

Debug.inspect(id): option<node>#

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.

Debug.idOfSignal(signal): option<int>#

Finds a signal or computed in the graph, given the value itself.

let count = Signal.make(0, ~name="count")

switch Debug.idOfSignal(count) {
| Some(id) => Debug.inspect(id)
| None => None // created before install
}

For a tool that holds a Signal.t some other library handed back, and wants to know which node it is.

globalThis[Symbol.for("rescript-signals.debug")]#

Every loaded copy of the library announces itself, so a tool can find it without importing it.

// JavaScript
const copies = globalThis[Symbol.for("rescript-signals.debug")] ?? []

for (const copy of copies) {
  // copy.version, copy.install, copy.uninstall,
  // copy.nodes, copy.inspect, copy.idOfSignal
  copy.install({ captureStacks: false, emit: event => console.log(event) })
}

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.

Changelog

The five most recent releases. This page documents 3.1.2, the version it was built against.

3.2.0

  • featadd Debug hooks for devtools

3.1.4

  • fixremove @rescript/core from dev-deps

3.1.3

  • perfcut allocations per effect and computed

3.1.2

  • fixdetach computeds when they lose subscribers

3.1.1

  • fixrestore Signals entrypoint module

Full release history on GitHub