Skip to content

Ultra-lightweight fine-grained reactivity for TypeScript. Signals, effects, derived values, and reactive collections with deep reactivity support.

Notifications You must be signed in to change notification settings

RLabs-Inc/signals

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@rlabs-inc/signals

Production-grade fine-grained reactivity for TypeScript.

A complete standalone mirror of Svelte 5's reactivity system. No compiler needed, no DOM, works anywhere - Bun, Node, Deno, or browser.

Features

  • True Fine-Grained Reactivity - Changes to deeply nested properties only trigger effects that read that exact path
  • Per-Property Tracking - Proxy-based deep reactivity with lazy signal creation per property
  • Three-State Dirty Tracking - Efficient CLEAN/MAYBE_DIRTY/DIRTY propagation
  • Automatic Cleanup - Effects clean up when disposed, no memory leaks
  • Batching - Group updates to prevent redundant effect runs
  • Self-Referencing Effects - Effects can write to their own dependencies
  • Infinite Loop Protection - Throws after 1000 iterations to catch bugs
  • Reactive Collections - ReactiveMap, ReactiveSet, ReactiveDate
  • TypeScript Native - Full type safety with generics

Installation

bun add @rlabs-inc/signals
# or
npm install @rlabs-inc/signals

Quick Start

import { signal, derived, effect, flushSync } from '@rlabs-inc/signals'

// Create a signal
const count = signal(0)

// Create a derived value
const doubled = derived(() => count.value * 2)

// Create an effect
effect(() => {
  console.log(`Count: ${count.value}, Doubled: ${doubled.value}`)
})

// Flush to run effects synchronously
flushSync()  // Logs: "Count: 0, Doubled: 0"

// Update the signal
count.value = 5
flushSync()  // Logs: "Count: 5, Doubled: 10"

API Reference

Signals

signal<T>(initialValue: T, options?): WritableSignal<T>

Create a reactive value with .value getter/setter.

const name = signal('John')
console.log(name.value)  // 'John'
name.value = 'Jane'      // Triggers effects

Options:

  • equals?: (a: T, b: T) => boolean - Custom equality function

state<T extends object>(initialValue: T): T

Create a deeply reactive object. No .value needed - access properties directly.

const user = state({ name: 'John', address: { city: 'NYC' } })
user.name = 'Jane'           // Reactive
user.address.city = 'LA'     // Also reactive, deeply

Derived Values

derived<T>(fn: () => T): DerivedSignal<T>

Create a computed value that automatically updates when dependencies change.

const firstName = signal('John')
const lastName = signal('Doe')

const fullName = derived(() => `${firstName.value} ${lastName.value}`)
console.log(fullName.value)  // 'John Doe'

Deriveds are:

  • Lazy - Only computed when read
  • Cached - Value is memoized until dependencies change
  • Pure - Cannot write to signals inside (throws error)

Effects

effect(fn: () => void | CleanupFn): DisposeFn

Create a side effect that re-runs when dependencies change.

const count = signal(0)

const dispose = effect(() => {
  console.log('Count is:', count.value)

  // Optional cleanup function
  return () => {
    console.log('Cleaning up...')
  }
})

// Stop the effect
dispose()

effect.root(fn: () => T): DisposeFn

Create an effect scope that can contain nested effects.

const dispose = effect.root(() => {
  effect(() => { /* ... */ })
  effect(() => { /* ... */ })
})

// Disposes all nested effects
dispose()

effect.pre(fn: () => void): DisposeFn

Create an effect that runs synchronously (like $effect.pre in Svelte).

effect.pre(() => {
  // Runs immediately, no flushSync needed
})

Batching & Scheduling

batch(fn: () => T): T

Batch multiple signal updates into a single effect run.

const a = signal(1)
const b = signal(2)

effect(() => console.log(a.value + b.value))

batch(() => {
  a.value = 10
  b.value = 20
})
// Effect runs once with final values, not twice

flushSync<T>(fn?: () => T): T | undefined

Synchronously flush all pending effects.

count.value = 5
flushSync()  // Effects run NOW, not on next microtask

tick(): Promise<void>

Wait for the next update cycle.

count.value = 5
await tick()  // Effects have run

Utilities

untrack<T>(fn: () => T): T

Read signals without creating dependencies.

effect(() => {
  const a = count.value           // Creates dependency
  const b = untrack(() => other.value)  // No dependency
})

peek<T>(signal: Source<T>): T

Read a signal's value without tracking (low-level).

Deep Reactivity

proxy<T extends object>(value: T): T

Create a deeply reactive proxy (used internally by state()).

const obj = proxy({ a: { b: { c: 1 } } })
obj.a.b.c = 2  // Only triggers effects reading a.b.c

toRaw<T>(value: T): T

Get the original object from a proxy.

const raw = toRaw(user)  // Original non-reactive object

isReactive(value: unknown): boolean

Check if a value is a reactive proxy.

Reactive Collections

ReactiveMap<K, V>

A Map with per-key reactivity.

const users = new ReactiveMap<string, User>()

effect(() => {
  console.log(users.get('john'))  // Only re-runs when 'john' changes
})

users.set('jane', { name: 'Jane' })  // Doesn't trigger above effect

ReactiveSet<T>

A Set with per-item reactivity.

const tags = new ReactiveSet<string>()

effect(() => {
  console.log(tags.has('important'))  // Only re-runs when 'important' changes
})

ReactiveDate

A Date with reactive getters/setters.

const date = new ReactiveDate()

effect(() => {
  console.log(date.getHours())  // Re-runs when time changes
})

date.setHours(12)  // Triggers effect

Advanced Usage

Self-Referencing Effects

Effects can write to signals they depend on:

const count = signal(0)

effect(() => {
  if (count.value < 10) {
    count.value++  // Will re-run until count reaches 10
  }
})

Note: Unguarded self-references throw after 1000 iterations.

Custom Equality

import { signal, shallowEquals } from '@rlabs-inc/signals'

const obj = signal({ a: 1 }, { equals: shallowEquals })
obj.value = { a: 1 }  // Won't trigger - shallowly equal

Built-in equality functions:

  • equals - Default, uses Object.is
  • safeEquals - Handles NaN correctly
  • shallowEquals - Shallow object comparison
  • neverEquals - Always triggers (always false)
  • alwaysEquals - Never triggers (always true)

Low-Level API

For advanced use cases, you can access internal primitives:

import { source, get, set } from '@rlabs-inc/signals'

// Create a raw source (no .value wrapper)
const src = source(0)

// Read with tracking
const value = get(src)

// Write with notification
set(src, 10)

Error Handling

"Cannot write to signals inside a derived"

Deriveds must be pure computations:

// BAD - will throw
const bad = derived(() => {
  otherSignal.value = 10  // Throws!
  return count.value
})

// GOOD - use effects for side effects
effect(() => {
  if (count.value > 0) {
    otherSignal.value = count.value * 2
  }
})

"Maximum update depth exceeded"

Your effect is infinitely re-triggering itself:

// BAD - infinite loop
effect(() => {
  count.value = count.value + 1  // Always triggers itself
})

// GOOD - add a guard
effect(() => {
  if (count.value < 100) {
    count.value++
  }
})

Performance

This library is designed for performance:

  • Lazy evaluation - Deriveds only compute when read
  • Version-based deduplication - No duplicate dependency tracking
  • Linked list effect tree - O(1) effect insertion/removal
  • Microtask batching - Updates coalesce automatically
  • Per-property signals - Fine-grained updates at any depth

Comparison with Svelte 5

Feature Svelte 5 @rlabs-inc/signals
Compiler required Yes No
DOM integration Yes No
Fine-grained reactivity Yes Yes
Deep proxy reactivity Yes Yes
Batching Yes Yes
Effect cleanup Yes Yes
TypeScript Yes Yes
Runs in Node/Bun Needs adapter Native

License

MIT

About

Ultra-lightweight fine-grained reactivity for TypeScript. Signals, effects, derived values, and reactive collections with deep reactivity support.

Topics

Resources

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •