Skip to content

Signals

State containers that drive Xote's reactive graph.

Signals are the state primitive in Xote. A signal stores a value, tracks who reads it, and notifies dependents when the value actually changes.

Info: Xote re-exports Signal, Computed, and Effect from rescript-signals.

Working with Signals

Creating Signals

Use Signal.make to create a signal. You can optionally pass ~name for debugging and ~equals when the default equality is not enough.

1open Xote
2
3let count = Signal.make(0)
4let userName = Signal.make("Ada", ~name="user-name")
5let settings = Signal.make({
6 theme: "dark",
7 compact: false,
8})

Reading Signal Values

There are two read modes. Choose based on whether the current code should subscribe to future updates.

Signal.get()

Use Signal.get inside a computed, effect, or reactive node when the current code should re-run if the signal changes.

1let firstName = Signal.make("Ada")
2let lastName = Signal.make("Lovelace")
3
4let fullName = Computed.make(() =>
5 Signal.get(firstName) ++ " " ++ Signal.get(lastName)
6)

Signal.peek()

Use Signal.peek when you need the current value without subscribing. This is useful for logging, snapshots, and one-off reads inside effects.

1Effect.run(() => {
2 let tracked = Signal.get(count)
3 let snapshot = Signal.peek(settings)
4
5 Console.log2("Tracked count:", tracked)
6 Console.log2("Current theme:", snapshot.theme)
7 None
8})

Updating Signals

Signal.set()

Use Signal.set when you already know the next value.

1Signal.set(count, 10)
2Signal.set(userName, "Grace")

Signal.update()

Use Signal.update when the next value depends on the current one. This keeps the intent obvious and avoids an extra read.

1Signal.update(count, n => n + 1)
2
3Signal.update(settings, current => {
4 ...current,
5 compact: !current.compact,
6})

How Signals Decide to Update

Equality and Change Detection

Signals only notify dependents when the new value is considered different from the current value. By default that check uses JavaScript strict equality, ===.

Default Equality

For primitives, setting the same value is a no-op. For arrays, records, and objects, a new reference counts as a change even when the fields look the same.

1let count = Signal.make(5)
2
3Signal.set(count, 5) // No update
4Signal.set(count, 6) // Notifies dependents
5
6let items = Signal.make([1, 2, 3])
7Signal.set(items, [1, 2, 3]) // New array reference, so this updates

Custom Equality

When you want value-based comparison for compound data, pass ~equals to Signal.make.

1type position = {x: int, y: int}
2
3let position = Signal.make(
4 {x: 0, y: 0},
5 ~equals=(a, b) => a.x == b.x && a.y == b.y,
6)
7
8Signal.set(position, {x: 0, y: 0}) // No update
9Signal.set(position, {x: 0, y: 1}) // Update

Dependency Tracking

Every Signal.get call inside an active computed or effect becomes a dependency. On the next run, dependencies are cleared and tracked again, so the graph follows control flow instead of staying fixed forever.

1let useMetric = Signal.make(true)
2let celsius = Signal.make(20)
3let fahrenheit = Signal.make(68)
4
5let temperature = Computed.make(() =>
6 if Signal.get(useMetric) {
7 Signal.get(celsius)
8 } else {
9 Signal.get(fahrenheit)
10 }
11)

In Practice

Example: Counter

This is the same pattern most Xote state starts with: a signal, a few updates, and a reactive read in the UI.

Counter.res
1open Xote
2
3let count = Signal.make(0)
4
5let increment = (_evt: Dom.event) => {
6 Signal.update(count, n => n + 1)
7}
8
9let decrement = (_evt: Dom.event) => {
10 Signal.update(count, n => n - 1)
11}
12
13let reset = (_evt: Dom.event) => {
14 Signal.set(count, 0)
15}
16
17let app = () => {
18 <div>
19 <h1>
20 {Node.signalText(() => "Count: " ++ Int.toString(Signal.get(count)))}
21 </h1>
22 <button onClick={increment}>
23 {Node.text("+")}
24 </button>
25 <button onClick={decrement}>
26 {Node.text("-")}
27 </button>
28 <button onClick={reset}>
29 {Node.text("Reset")}
30 </button>
31 </div>
32}
33
34Node.mountById(app(), "app")
Signal state

Counter

Neutral
0
Current Count
One writable signal updates the UI immediately when the value changes.
fig. 1 - a counter driven by one signal

Working Style

Best Practices

  • Keep one signal focused on one job. A small record is fine; a grab-bag of unrelated state is not.
  • Prefer Signal.update when the next value depends on the current one.
  • Treat Signal.peek as a snapshot tool, not your default read API.
  • Add custom equality only when strict equality creates real noise in the UI or effects.

Next Steps