npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

rescript-signals

v1.3.3

Published

Reactive Signals primitives implemented in ReScript.

Readme

rescript-signals

Release npm version bundlephobia

A lightweight, high-performance reactive signals library for ReScript with zero dependencies. Build reactive applications with fine-grained updates, automatic dependency tracking, and minimal re-computation.

Installation

npm install rescript-signals

Add to your rescript.json:

{
  "dependencies": ["rescript-signals"]
}

Key Features

  • Reactive State: Signals track changes and notify dependents automatically
  • Computed Values: Derived state with lazy evaluation and automatic caching
  • Side Effects: Run code in response to signal changes with automatic cleanup
  • Dependency Tracking: No manual subscriptions—dependencies are tracked automatically
  • Batched Updates: Group multiple updates to prevent redundant computations
  • Untracked Reads: Access signal values without creating dependencies
  • Fine-grained Updates: Only affected computations re-run, nothing more
  • Level-based Scheduling: Guarantees correct execution order for complex dependency graphs
  • Type-safe: Full ReScript type safety with zero runtime overhead
  • Debuggable: Optional naming for signals, computed values, and effects

Quick Start

open Signals

// Create a signal
let count = Signal.make(0)

// Create a computed value (updates automatically)
let doubled = Computed.make(() => Signal.get(count) * 2)

// Run a side effect (executes when dependencies change)
let disposer = Effect.run(() => {
  Console.log(`Count: ${Int.toString(Signal.get(count))}, Doubled: ${Int.toString(Signal.get(doubled))}`)
  None
})

// Update the signal
Signal.set(count, 5) // Logs: "Count: 5, Doubled: 10"

// Clean up when done
disposer.dispose()

Usage

Signals

Signals are reactive containers for values. When a signal's value changes, all dependent computations and effects are automatically updated.

Creating and Reading Signals

open Signals

// Create a signal with an initial value
let count = Signal.make(0)

// Read the value with dependency tracking
let value = Signal.get(count)

// Read without tracking (in effects/computed)
let value = Signal.peek(count)

Updating Signals

// Set a new value
Signal.set(count, 1)

// Update based on current value
Signal.update(count, n => n + 1)

Advanced Signal Options

// Custom equality to prevent unnecessary updates
let position = Signal.make(
  {x: 0, y: 0},
  ~equals=(a, b) => a.x === b.x && a.y === b.y
)

// Named signals for debugging
let userCount = Signal.make(0, ~name="userCount")

Computed Values

Computed signals derive their value from other signals. They're lazily evaluated and automatically cache results until dependencies change.

Basic Computed

let count = Signal.make(5)
let doubled = Computed.make(() => Signal.get(count) * 2)

Console.log(Signal.peek(doubled)) // 10

Signal.set(count, 10)
Console.log(Signal.peek(doubled)) // 20

Computed from Multiple Signals

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

let fullName = Computed.make(() =>
  Signal.get(firstName) ++ " " ++ Signal.get(lastName)
)

Console.log(Signal.peek(fullName)) // "John Doe"

Chained Computed Values

let base = Signal.make(2)
let doubled = Computed.make(() => Signal.get(base) * 2)
let quadrupled = Computed.make(() => Signal.get(doubled) * 2)

Console.log(Signal.peek(quadrupled)) // 8
Signal.set(base, 3)
Console.log(Signal.peek(quadrupled)) // 12

Named Computed for Debugging

let total = Computed.make(
  () => Signal.get(price) * Signal.get(quantity),
  ~name="orderTotal"
)

Disposal

Computed values are automatically cleaned up when no longer referenced, but you can manually dispose of them:

Computed.dispose(doubled)

Effects

Effects run side effects in response to signal changes. They execute immediately and re-run whenever tracked dependencies change.

Basic Effect

let count = Signal.make(0)

let disposer = Effect.run(() => {
  Console.log(`Count is: ${Int.toString(Signal.get(count))}`)
  None
})

Signal.set(count, 1) // Logs: "Count is: 1"
disposer.dispose()

Effect with Cleanup

Effects can return a cleanup function that runs before the next execution and on disposal:

let url = Signal.make("/api/data")

let disposer = Effect.run(() => {
  let currentUrl = Signal.get(url)

  // Start async operation
  let abortController = fetchData(currentUrl)

  // Return cleanup function
  Some(() => {
    Console.log("Cancelling previous fetch")
    abortController.abort()
  })
})

Signal.set(url, "/api/other") // Cleanup runs, then effect re-executes
disposer.dispose() // Final cleanup

Named Effects for Debugging

let disposer = Effect.run(
  () => {
    Console.log(Signal.get(count))
    None
  },
  ~name="countLogger"
)

Conditional Dependencies

Effects only track signals read during execution:

let showDetails = Signal.make(false)
let userData = Signal.make({name: "John"})
let adminData = Signal.make({role: "admin"})

let disposer = Effect.run(() => {
  if Signal.get(showDetails) {
    Console.log(Signal.get(userData)) // Tracked
  } else {
    Console.log("Hidden")
    // adminData is not tracked in this branch
  }
  None
})

Advanced Features

Batching Updates

Batch multiple signal updates to prevent redundant effect executions. All updates within a batch are applied before any effects run:

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

let disposer = Effect.run(() => {
  Console.log(Signal.get(firstName) ++ " " ++ Signal.get(lastName))
  runCount := runCount.contents + 1
  None
})

// Without batching: effect runs twice (once per update)
Signal.set(firstName, "Jane")
Signal.set(lastName, "Smith")

// With batching: effect runs only once
Signal.batch(() => {
  Signal.set(firstName, "Alice")
  Signal.set(lastName, "Johnson")
})

// Batches can be nested and return values
let result = Signal.batch(() => {
  Signal.set(firstName, "Bob")
  Signal.set(lastName, "Brown")
  "Updated!"
})

Untracked Reads

Read signal values without creating dependencies. Useful when you need a value but don't want to re-run when it changes:

let count = Signal.make(0)
let threshold = Signal.make(10)

let disposer = Effect.run(() => {
  let current = Signal.get(count)
  let limit = Signal.untrack(() => Signal.get(threshold))

  if current > limit {
    Console.log("Count exceeds threshold!")
  }
  None
})

// This triggers the effect (count is tracked)
Signal.set(count, 15)

// This does NOT trigger the effect (threshold is untracked)
Signal.set(threshold, 20)

Untracked reads can be nested and return values:

let value = Signal.untrack(() => {
  let a = Signal.get(signalA)
  let b = Signal.get(signalB)
  a + b
})

Performance Characteristics

  • Lazy Evaluation: Computed values are only recalculated when read, not when dependencies change
  • Smart Caching: Computed values cache results and only recompute when dependencies actually change
  • Minimal Updates: Signals use equality checking (customizable) to prevent unnecessary notifications
  • Glitch-free: Level-based scheduling ensures derived values never see inconsistent state
  • Efficient Scheduling: Effects and computed values execute in correct topological order
  • Zero Overhead: When signals don't change, there's no runtime cost

API Reference

Signal

The core reactive primitive for storing state.

type t<'a>

// Create a new signal
let make: (
  'a,
  ~name: option<string>=?,
  ~equals: option<('a, 'a) => bool>=?
) => t<'a>

// Read with dependency tracking
let get: t<'a> => 'a

// Read without dependency tracking
let peek: t<'a> => 'a

// Set a new value
let set: (t<'a>, 'a) => unit

// Update based on current value
let update: (t<'a>, 'a => 'a) => unit

// Batch multiple updates
let batch: (unit => 'a) => 'a

// Read without tracking dependencies
let untrack: (unit => 'a) => 'a

Parameters:

  • initialValue: The initial value for the signal
  • ~name: Optional name for debugging
  • ~equals: Optional custom equality function (default: ===)

Returns: A signal that can be read and updated

Computed

Create derived values that update automatically.

// Create a computed value
let make: (
  unit => 'a,
  ~name: option<string>=?
) => Signal.t<'a>

// Manually dispose a computed value
let dispose: Signal.t<'a> => unit

Parameters:

  • fn: Function that computes the derived value
  • ~name: Optional name for debugging

Returns: A signal containing the computed value (read-only)

Note: Computed values use lazy evaluation—they only recalculate when read after dependencies change.

Effect

Run side effects in response to signal changes.

type disposer = {dispose: unit => unit}

// Run an effect
let run: (
  unit => option<unit => unit>,
  ~name: option<string>=?
) => disposer

Parameters:

  • fn: Effect function to execute. Can return None or Some(cleanupFn)
  • ~name: Optional name for debugging

Returns: A disposer object with a dispose() method

Note: Effects run immediately and re-run whenever tracked dependencies change. Cleanup functions run before re-execution and on disposal.

Common Patterns

Form State Management

type formData = {
  name: string,
  email: string,
  age: int,
}

let formData = Signal.make({name: "", email: "", age: 0})

// Computed validation
let isValid = Computed.make(() => {
  let data = Signal.get(formData)
  data.name !== "" && data.email->String.includes("@") && data.age >= 18
})

// Effect for auto-save
let disposer = Effect.run(() => {
  if Signal.get(isValid) {
    saveToLocalStorage(Signal.get(formData))
  }
  None
})

Async Data Fetching

let userId = Signal.make(1)
let userData = Signal.make(None)
let isLoading = Signal.make(false)

let disposer = Effect.run(() => {
  let id = Signal.get(userId)

  Signal.set(isLoading, true)

  fetchUser(id)->Promise.then(user => {
    Signal.set(userData, Some(user))
    Signal.set(isLoading, false)
  })->ignore

  Some(() => {
    // Cancel previous request if needed
    cancelFetch(id)
  })
})

Derived Collections

let todos = Signal.make([
  {id: 1, text: "Learn ReScript", completed: false},
  {id: 2, text: "Build an app", completed: true},
])

let filter = Signal.make("all") // "all" | "active" | "completed"

let filteredTodos = Computed.make(() => {
  let items = Signal.get(todos)
  let currentFilter = Signal.get(filter)

  switch currentFilter {
  | "active" => items->Array.filter(t => !t.completed)
  | "completed" => items->Array.filter(t => t.completed)
  | _ => items
  }
})

let completedCount = Computed.make(() =>
  Signal.get(todos)->Array.filter(t => t.completed)->Array.length
)

Coordinated Updates

let x = Signal.make(0)
let y = Signal.make(0)
let z = Signal.make(0)

// Update all coordinates atomically
let movePoint = (dx, dy, dz) => {
  Signal.batch(() => {
    Signal.update(x, v => v + dx)
    Signal.update(y, v => v + dy)
    Signal.update(z, v => v + dz)
  })
}

// Effect only runs once per movePoint call
let disposer = Effect.run(() => {
  Console.log(
    `Position: (${Int.toString(Signal.get(x))}, ${Int.toString(Signal.get(y))}, ${Int.toString(Signal.get(z))})`
  )
  None
})

Performance Optimization with Untrack

// Expensive configuration that rarely changes
let config = Signal.make({theme: "dark", locale: "en"})

// Frequently changing data
let data = Signal.make([])

let disposer = Effect.run(() => {
  let items = Signal.get(data)

  // Read config without tracking—we'll manually refresh when config changes
  let currentConfig = Signal.untrack(() => Signal.get(config))

  renderUI(items, currentConfig)
  None
})

Development

See CONTRIBUTING.md for development setup and guidelines.

Running Tests

npm test

Building

npm run build

Watching for Changes

npm run watch

License

See LICENSE for details.