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.
- 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
bun add @rlabs-inc/signals
# or
npm install @rlabs-inc/signalsimport { 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"Create a reactive value with .value getter/setter.
const name = signal('John')
console.log(name.value) // 'John'
name.value = 'Jane' // Triggers effectsOptions:
equals?: (a: T, b: T) => boolean- Custom equality function
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, deeplyCreate 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)
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()Create an effect scope that can contain nested effects.
const dispose = effect.root(() => {
effect(() => { /* ... */ })
effect(() => { /* ... */ })
})
// Disposes all nested effects
dispose()Create an effect that runs synchronously (like $effect.pre in Svelte).
effect.pre(() => {
// Runs immediately, no flushSync needed
})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 twiceSynchronously flush all pending effects.
count.value = 5
flushSync() // Effects run NOW, not on next microtaskWait for the next update cycle.
count.value = 5
await tick() // Effects have runRead signals without creating dependencies.
effect(() => {
const a = count.value // Creates dependency
const b = untrack(() => other.value) // No dependency
})Read a signal's value without tracking (low-level).
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.cGet the original object from a proxy.
const raw = toRaw(user) // Original non-reactive objectCheck if a value is a reactive proxy.
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 effectA Set with per-item reactivity.
const tags = new ReactiveSet<string>()
effect(() => {
console.log(tags.has('important')) // Only re-runs when 'important' changes
})A Date with reactive getters/setters.
const date = new ReactiveDate()
effect(() => {
console.log(date.getHours()) // Re-runs when time changes
})
date.setHours(12) // Triggers effectEffects 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.
import { signal, shallowEquals } from '@rlabs-inc/signals'
const obj = signal({ a: 1 }, { equals: shallowEquals })
obj.value = { a: 1 } // Won't trigger - shallowly equalBuilt-in equality functions:
equals- Default, usesObject.issafeEquals- Handles NaN correctlyshallowEquals- Shallow object comparisonneverEquals- Always triggers (always false)alwaysEquals- Never triggers (always true)
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)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
}
})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++
}
})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
| 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 |
MIT