Skip to content

Signals

Reactive state cells that form the foundation of Xote's reactivity model.

Signals

Signals are the foundation of reactive state in Xote. A signal is a reactive state container that automatically notifies its dependents when its value changes.

Info: Xote re-exports Signal, Computed, and Effect from rescript-signals. The API and behavior are provided by that library.

Creating Signals

Use Signal.make() to create a new signal with an initial value:

open Xote

let count = Signal.make(0)
let name = Signal.make("Alice")
let isActive = Signal.make(true)

Reading Signal Values

Signal.get()

Use Signal.get() to read a signal's value. When called inside a tracking context (like an effect or computed value), it automatically registers the signal as a dependency:

let count = Signal.make(5)
let value = Signal.get(count) // Returns 5

Signal.peek()

Use Signal.peek() to read a signal's value without creating a dependency:

let count = Signal.make(5)

Effect.run(() => {
  // This creates a dependency
  let current = Signal.get(count)

  // This does NOT create a dependency
  let peeked = Signal.peek(count)

  Console.log2("Current:", current)
  Console.log2("Peeked:", peeked)
})

Updating Signals

Signal.set()

Replace a signal's value entirely:

let count = Signal.make(0)
Signal.set(count, 10) // count is now 10

Signal.update()

Update a signal based on its current value:

let count = Signal.make(0)
Signal.update(count, n => n + 1) // count is now 1
Signal.update(count, n => n * 2) // count is now 2

Important Behaviors

Structural Equality Check

Signals use structural equality (==) to check if a value has changed. If the new value equals the old value, dependents are not notified:

let count = Signal.make(5)

Effect.run(() => {
  Console.log(Signal.get(count))
})

Signal.set(count, 5) // Effect does NOT run - value didn't change
Signal.set(count, 6) // Effect runs - value changed

This prevents unnecessary updates and helps avoid accidental infinite loops in reactive code.

Custom Equality Check

By default, signals use strict referential equality (===) to determine if a value has changed. For complex types like records or objects where structurally equivalent values may have different references, you can provide a custom equality function via the ~equals parameter:

type position = { x: int, y: int }

// Without custom equality: every set() triggers updates,
// even if x and y are the same
let pos1 = Signal.make({ x: 0, y: 0 })

// With custom equality: only triggers updates when
// x or y actually change
let pos2 = Signal.make(
  { x: 0, y: 0 },
  ~equals=(a, b) => a.x == b.x && a.y == b.y,
)

Effect.run(() => {
  let { x, y } = Signal.get(pos2)
  Console.log(`Position: ${Int.toString(x)}, ${Int.toString(y)}`)
  None
})

// This will NOT trigger the effect - values are equal
Signal.set(pos2, { x: 0, y: 0 })

// This WILL trigger the effect - y changed
Signal.set(pos2, { x: 0, y: 1 })

Custom equality is useful when working with records, tuples, or other compound types where you want to compare by value rather than by reference.

Automatic Dependency Tracking

When you call Signal.get() inside a tracking context, the dependency is automatically registered:

let firstName = Signal.make("John")
let lastName = Signal.make("Doe")

// This computed automatically depends on both firstName and lastName
let fullName = Computed.make(() =>
  Signal.get(firstName) ++ " " ++ Signal.get(lastName)
)

Example: Counter

Here's a complete example showing signals in action:

open Xote

let count = Signal.make(0)

let increment = (_evt: Dom.event) => {
  Signal.update(count, n => n + 1)
}

let decrement = (_evt: Dom.event) => {
  Signal.update(count, n => n - 1)
}

let reset = (_evt: Dom.event) => {
  Signal.set(count, 0)
}

let app = () => {
  <div>
    <h1>
      {Node.signalText(() => "Count: " ++ Int.toString(Signal.get(count)))}
    </h1>
    <button onClick={increment}>
      {Node.text("+")}
    </button>
    <button onClick={decrement}>
      {Node.text("-")}
    </button>
    <button onClick={reset}>
      {Node.text("Reset")}
    </button>
  </div>
}

Node.mountById(app(), "app")

Best Practices

  • Keep signals focused: Each signal should represent a single piece of state
  • Use peek() to avoid dependencies: When you need to read a value without tracking, use peek()
  • Prefer update() over get() + set(): It's more concise and clearer in intent

Next Steps

Was this page helpful?