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
countdoubledeffect
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

Three modules, and that is the whole surface. Every signature below was read off the installed 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.

Changelog

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

3.1.2

  • fixdetach computeds when they lose subscribers

3.1.1

  • fixrestore Signals entrypoint module

3.1.0

  • featadd optional equals for computeds
  • perfoptimize scheduler fast paths

3.0.0

breaking
  • featconvert to npm workspaces monorepo
  • featadd rescript-signals-react package

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.

2.1.0

  • featuse ReScript namespacing for Signals

Full release history on GitHub