Production-grade fine-grained reactivity for TypeScript.
A complete standalone implementation of modern reactivity patterns, combining the best of Svelte 5, Angular, Solid, and Vue. No compiler needed, no DOM dependencies - works anywhere: Bun, Node, Deno, or browser.
- Fine-Grained Reactivity - Changes only trigger effects that depend on them
- Deep Reactivity - Proxy-based tracking at any nesting depth
- Zero Dependencies - Pure TypeScript, ~8KB minified
- Framework Patterns - linkedSignal (Angular), createSelector (Solid), effectScope (Vue)
- Sync & Async Effects - Choose predictable timing or automatic batching
- Automatic Cleanup - FinalizationRegistry ensures proper garbage collection
Benchmarked against Preact Signals (the fastest mainstream implementation):
| Operation | @rlabs-inc/signals | Preact Signals |
|---|---|---|
| Signal read | 2ns | 3ns |
| Signal write | 15ns | 12ns |
| Effect run | 20ns | 25ns |
| Derived read | 5ns | 5ns |
Performance is competitive while offering significantly more features.
bun add @rlabs-inc/signals
# or
npm install @rlabs-inc/signalsimport { signal, derived, effect, flushSync } from '@rlabs-inc/signals'
// Create a signal (reactive value)
const count = signal(0)
// Create a derived (computed value)
const doubled = derived(() => count.value * 2)
// Create an effect (side effect)
effect(() => {
console.log(`Count: ${count.value}, Doubled: ${doubled.value}`)
})
// Flush to run effects synchronously (for testing/demos)
flushSync() // Logs: "Count: 0, Doubled: 0"
// Update the signal
count.value = 5
flushSync() // Logs: "Count: 5, Doubled: 10"- Core Primitives
- Effects
- Advanced Primitives
- Bindings & Slots
- Shared Memory
- Deep Reactivity
- Utilities
- Reactive Collections
- Equality Functions
- Error Handling
Create a reactive value with .value getter/setter.
signal<T>(initialValue: T, options?: { equals?: (a: T, b: T) => boolean }): WritableSignal<T>const name = signal('John')
console.log(name.value) // 'John'
name.value = 'Jane' // Triggers effects that depend on it
// With custom equality (skip updates when structurally equal)
const user = signal({ name: 'John' }, { equals: deepEquals })Create multiple signals at once from an object.
signals<T>(initial: T): { [K in keyof T]: WritableSignal<T[K]> }// Instead of:
const content = signal('hello')
const width = signal(40)
const visible = signal(true)
// Do this:
const ui = signals({ content: 'hello', width: 40, visible: true })
ui.content.value = 'updated'
ui.width.value = 60Create a computed value that automatically updates when dependencies change.
derived<T>(fn: () => T, options?: { equals?: Equals<T> }): DerivedSignal<T>const firstName = signal('John')
const lastName = signal('Doe')
const fullName = derived(() => `${firstName.value} ${lastName.value}`)
console.log(fullName.value) // 'John Doe'
firstName.value = 'Jane'
console.log(fullName.value) // 'Jane 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 deeply reactive object. No .value needed - access properties directly.
state<T extends object>(initialValue: T): Tconst user = state({
name: 'John',
address: { city: 'NYC', zip: '10001' }
})
// All property access is reactive
user.name = 'Jane' // Triggers effects reading user.name
user.address.city = 'LA' // Triggers effects reading user.address.city
// Effects reading user.name are NOT triggered by city change (fine-grained!)Create a signal that holds an object reference without deep reactivity. Only triggers when the reference changes, not on mutations.
stateRaw<T>(initialValue: T): WritableSignal<T>const canvas = stateRaw(document.createElement('canvas'))
// Only triggers when canvas.value is reassigned
// Mutations to the canvas element don't trigger effectsUse stateRaw for:
- DOM elements
- Class instances
- Large objects where you only care about replacement
Effects are the bridge between reactive state and the outside world. They re-run when their dependencies change.
Create an effect that runs asynchronously via microtask. Multiple signal changes are automatically batched.
effect(fn: () => void | CleanupFn): DisposeFnconst count = signal(0)
const dispose = effect(() => {
console.log('Count is:', count.value)
// Optional cleanup function - runs before next execution
return () => console.log('Cleaning up...')
})
count.value = 1
count.value = 2
count.value = 3
// Effect runs ONCE with final value (3) on next microtask
// Stop the effect
dispose()When to use: Most UI work, general reactivity. The automatic batching provides better throughput.
Create a synchronous effect that runs immediately when dependencies change. Combine with batch() for best performance.
effect.sync(fn: () => void | CleanupFn): DisposeFnconst count = signal(0)
effect.sync(() => {
console.log('Count:', count.value)
})
// Logs: "Count: 0" (runs immediately)
count.value = 1 // Logs: "Count: 1" (runs immediately)
count.value = 2 // Logs: "Count: 2" (runs immediately)
// For better performance with multiple writes, use batch():
batch(() => {
count.value = 10
count.value = 20
count.value = 30
})
// Logs: "Count: 30" (runs once at end of batch)When to use:
- Debugging (predictable execution order)
- Testing (synchronous assertions)
- Sequential logic where timing matters
- When you need immediate side effects
Create a root effect scope that can contain nested effects.
effect.root(fn: () => void): DisposeFnconst dispose = effect.root(() => {
effect(() => console.log('Effect A'))
effect(() => console.log('Effect B'))
effect.sync(() => console.log('Sync Effect C'))
})
// Later, clean up ALL nested effects at once
dispose()Check if currently inside a reactive tracking context.
if (effect.tracking()) {
console.log('Inside an effect or derived')
}Alias for effect.sync(). Use effect.sync() instead for clarity.
Create a writable signal that derives from a source but can be manually overridden. When the source changes, the linked signal resets to the computed value.
linkedSignal<D>(fn: () => D): WritableSignal<D>
linkedSignal<S, D>(options: LinkedSignalOptions<S, D>): WritableSignal<D>Simple form - dropdown selection:
const options = signal(['a', 'b', 'c'])
const selected = linkedSignal(() => options.value[0])
console.log(selected.value) // 'a'
selected.value = 'b' // Manual override
console.log(selected.value) // 'b'
options.value = ['x', 'y'] // Source changes
flushSync()
console.log(selected.value) // 'x' (reset to first item)Advanced form - keep valid selection:
const items = signal([1, 2, 3])
const selectedItem = linkedSignal({
source: () => items.value,
computation: (newItems, prev) => {
// Keep selection if still valid
if (prev && newItems.includes(prev.value)) {
return prev.value
}
return newItems[0]
}
})Form input that resets when data reloads:
const user = signal({ name: 'Alice' })
const editName = linkedSignal(() => user.value.name)
// User types in input
editName.value = 'Bob'
console.log(editName.value) // 'Bob'
// When user data reloads from server
user.value = { name: 'Charlie' }
flushSync()
console.log(editName.value) // 'Charlie' (reset!)Create a selector function for efficient list selection tracking. Instead of O(n) effects re-running, only affected items run = O(2).
createSelector<T, U = T>(
source: () => T,
fn?: (key: U, value: T) => boolean
): SelectorFn<T, U>const selectedId = signal(1)
const isSelected = createSelector(() => selectedId.value)
// In a list of 1000 items:
items.forEach(item => {
effect(() => {
// Only runs when THIS item's selection state changes!
if (isSelected(item.id)) {
highlight(item)
} else {
unhighlight(item)
}
})
})
// When selectedId changes from 1 to 2:
// - Only item 1's effect runs (was selected, now not)
// - Only item 2's effect runs (was not selected, now is)
// - Other 998 items' effects DON'T run!Create an effect scope to group effects for batch disposal with pause/resume support.
effectScope(detached?: boolean): EffectScope
interface EffectScope {
readonly active: boolean
readonly paused: boolean
run<R>(fn: () => R): R | undefined
stop(): void
pause(): void
resume(): void
}const scope = effectScope()
scope.run(() => {
effect(() => console.log(count.value))
effect(() => console.log(name.value))
// Register cleanup to run when scope stops
onScopeDispose(() => {
console.log('Cleaning up...')
})
})
// Pause execution temporarily
scope.pause()
count.value = 5 // Effect doesn't run
// Resume and run pending updates
scope.resume() // Now effect runs with value 5
// Later, dispose all effects at once
scope.stop() // Runs onScopeDispose callbacksRelated utilities:
// Register cleanup on current scope
onScopeDispose(() => clearInterval(timer))
// Get the currently active scope
const scope = getCurrentScope()Create a reactive binding - a two-way pointer that forwards reads and writes to a source signal.
bind<T>(source: WritableSignal<T> | Binding<T> | T | (() => T)): Binding<T>const source = signal(0)
const binding = bind(source)
// Reading through binding reads from source
console.log(binding.value) // 0
// Writing through binding writes to source
binding.value = 42
console.log(source.value) // 42Overloads:
bind(signal)- Creates writable binding to signalbind(binding)- Chains bindings (both point to same source)bind(value)- Wraps raw value in a signalbind(() => expr)- Creates read-only binding from getter
// Read-only binding from getter
const count = signal(5)
const doubled = bind(() => count.value * 2)
console.log(doubled.value) // 10
// doubled.value = 20 // Would throw!Create a stable reactive cell that can point to different sources. Unlike bind(), a Slot is never replaced - you mutate its source.
slot<T>(initial?: T): Slot<T>const mySlot = slot<string>('hello')
// Static value
console.log(mySlot.value) // 'hello'
mySlot.source = 'world'
console.log(mySlot.value) // 'world'
// Point to a signal (two-way binding!)
const name = signal('Alice')
mySlot.source = name
console.log(mySlot.value) // 'Alice'
mySlot.set('Bob') // Writes through to signal!
console.log(name.value) // 'Bob'
// Point to a getter (read-only, auto-tracks)
const count = signal(5)
mySlot.source = () => `Count: ${count.value}`
console.log(mySlot.value) // 'Count: 5'
count.value = 10
console.log(mySlot.value) // 'Count: 10'Key difference from bind():
bind()creates a NEW object each time - if you replace it, deriveds tracking the old one miss updatesslot()is STABLE - you change its source, and all dependents are notified
Create an array of slots for efficient reactive arrays (parallel array / ECS pattern).
slotArray<T>(defaultValue?: T): SlotArray<T>const textContent = slotArray<string>('')
// Set source (component setup)
textContent.setSource(0, props.content)
// Read value (auto-unwraps, auto-tracks!)
const content = textContent[0]
// Write value (two-way binding)
textContent.setValue(0, 'new text')
// Get raw slot for advanced use
const rawSlot = textContent.slot(0)SlotArray methods:
| Method | Description |
|---|---|
arr[i] |
Read value at index (auto-unwraps, auto-tracks) |
arr.setSource(i, src) |
Set what slot i points to |
arr.setValue(i, val) |
Write through to slot i's source |
arr.slot(i) |
Get the raw Slot at index |
arr.ensureCapacity(n) |
Expand to at least n slots |
arr.clear(i) |
Reset slot i to default |
Create an array of slots with automatic dirty tracking - perfect for incremental computation and ECS-style architectures.
trackedSlotArray<T>(defaultValue?: T, dirtySet: ReactiveSet<number>): SlotArray<T>const dirty = new ReactiveSet<number>()
const positions = trackedSlotArray({ x: 0, y: 0 }, dirty)
// Set source - automatically marks index as dirty
positions.setSource(0, signal({ x: 10, y: 20 })) // dirty.has(0) === true
// setValue also marks dirty
positions.setValue(5, { x: 100, y: 200 }) // dirty.has(5) === true
// Use dirty tracking for O(1) skips
const layout = derived(() => {
if (dirty.size === 0) return cachedLayout // Nothing changed!
// Process only dirty indices
for (const index of dirty) {
updateLayout(index, positions[index])
}
dirty.clear()
return computedLayout
})Three-level reactivity:
- Per-index tracking -
dirty.has(5)only subscribes to index 5 - Size tracking -
dirty.sizetracks count of dirty indices (perfect for "anything changed?" checks) - Version tracking - Iterating
for (const i of dirty)tracks structural changes
Use cases:
- ECS systems - Track which entities changed, skip unchanged ones
- Layout engines - Recompute only components with changed properties
- Incremental compilation - Track which modules changed
- Dirty checking - Skip expensive computations when nothing changed
Comparison with slotArray:
| Feature | slotArray | trackedSlotArray |
|---|---|---|
| Basic functionality | ✅ | ✅ |
| Automatic dirty tracking | ❌ | ✅ |
| O(1) skip optimization | ❌ | ✅ |
| Drop-in replacement | - | ✅ (just add dirtySet param) |
TypedArray-backed reactive slots for zero-copy FFI integration. The .buffer property provides direct access to the underlying TypedArray for passing to native code (Rust NAPI, Zig FFI, WebAssembly).
typedSlotArray<T extends TypedArrayConstructor>(
ArrayType: T,
capacity: number,
dirtySet?: ReactiveSet<number>,
defaultValue?: number
): TypedSlotArray<T>const dirty = new ReactiveSet<number>()
const positions = typedSlotArray(Float32Array, 1024, dirty, 0)
// Bind reactive sources
positions.setSource(0, xSignal)
positions.setSource(1, () => computeY())
// Sync reactive values to buffer
positions.sync()
// Pass directly to FFI - zero copy!
nativeLayout(positions.buffer) // Float32ArrayKey features:
- Zero-copy FFI -
.bufferis the actual TypedArray, pass directly to native code - Reactive binding -
setSource()binds signals/getters to indices - Dirty tracking - Automatically tracks which indices changed
- Manual sync - Call
sync()to flush reactive values to buffer
Create multiple typedSlotArrays with shared dirty tracking. Perfect for ECS-style parallel arrays where you need to know which entities changed across all component arrays.
typedSlotArrayGroup<Config>(
config: Config,
capacity: number,
dirtySet?: ReactiveSet<number>
): TypedSlotArrayGroup<Config>const dirty = new ReactiveSet<number>()
const layout = typedSlotArrayGroup({
width: { type: Float32Array, defaultValue: NaN },
height: { type: Float32Array, defaultValue: NaN },
flexDirection: { type: Uint8Array, defaultValue: 0 },
visible: { type: Uint8Array, defaultValue: 1 },
}, 1024, dirty)
// Bind component props to arrays
layout.arrays.width.setSource(componentIndex, props.width)
layout.arrays.height.setSource(componentIndex, props.height)
// In layout derived: sync all arrays and get dirty indices
const dirtyIndices = layout.syncAndGetDirty()
// Pass buffers to native layout engine
nativeLayoutEngine(
new Uint32Array(dirtyIndices),
layout.arrays.width.buffer,
layout.arrays.height.buffer,
layout.arrays.flexDirection.buffer,
layout.arrays.visible.buffer
)Use cases:
- Native layout engines - Taffy, Yoga via FFI
- Game engines - ECS with native physics/rendering
- Data processing - Pass parallel arrays to WASM/native
Normalize component props to a consistent reactive interface. Accepts static values, getter functions, or signals - returns an object where every property is a DerivedSignal.
reactiveProps<T>(rawProps: T): ReactiveProps<UnwrapPropInputs<T>>interface MyComponentProps {
name: PropInput<string>
count: PropInput<number>
active: PropInput<boolean>
}
function MyComponent(rawProps: MyComponentProps) {
// Convert any mix of static/getter/signal props to consistent interface
const props = reactiveProps(rawProps)
// Everything is now a DerivedSignal - consistent .value access
const greeting = derived(() => `Hello, ${props.name.value}!`)
const doubled = derived(() => props.count.value * 2)
}
// All of these work identically:
MyComponent({ name: "world", count: 42, active: true })
MyComponent({ name: () => getName(), count: countSignal, active: true })
MyComponent({ name: nameSignal, count: () => getCount(), active: activeSignal })Why use reactiveProps?
| Without reactiveProps | With reactiveProps |
|---|---|
| Consumer must know which props need getters | Consumer just passes values |
| Component must handle multiple input types | Component always gets DerivedSignal |
Easy to forget () => and get stale values |
Props are always reactive |
Cross-language reactive shared memory primitives. Three layers that connect independent reactive graphs (e.g., TypeScript and Rust) through SharedArrayBuffer with zero serialization.
Reactive typed arrays backed by shared memory. get() tracks dependencies, set() writes + notifies the reactive graph + notifies the other side.
import { sharedSlotBuffer, sharedSlotBufferGroup, NoopNotifier } from '@rlabs-inc/signals'
// Create a buffer backed by SharedArrayBuffer
const sab = new SharedArrayBuffer(4096 * 4)
const widths = sharedSlotBuffer({
buffer: new Float32Array(sab),
notifier: new NoopNotifier(), // or AtomicsNotifier for cross-thread
})
// Reactive read — tracks dependency in deriveds/effects
const w = widths.get(0)
// Write — updates shared memory + marks reactions dirty + notifies
widths.set(0, 150.0)
// Batch write — single notification at the end
widths.setBatch([[0, 100], [1, 200], [2, 300]])
// Direct TypedArray access for FFI
nativeEngine(widths.raw) // Float32Array on SharedArrayBuffer
// Create a group of buffers with shared dirty tracking
const layout = sharedSlotBufferGroup({
width: { buffer: new Float32Array(sab, 0, 4096), notifier },
height: { buffer: new Float32Array(sab, 4096 * 4, 4096), notifier },
})A new reactive graph primitive — NOT an effect, NOT a derived. A purpose-built forwarding node that runs inline during markReactions with zero scheduling overhead. ~40-50 bytes per binding (vs ~200+ for Effect).
import { signal, repeat } from '@rlabs-inc/signals'
const myWidth = signal(100)
// Bind signal → buffer position
// When myWidth changes, the repeater forwards the value inline
const dispose = repeat(myWidth, widthBuffer, 0)
// That's it. No effect scheduling, no microtask delay.
// myWidth.value = 200 → buffer[0] is 200 during the same markReactions passHow it works:
myWidth.value = 200
→ markReactions(myWidth)
→ encounters REPEATER node
→ calls forward() INLINE (not scheduled)
→ reads myWidth (already 200)
→ writes buffer[0] = 200
→ sets dirty flag
→ notifier.notify() (batched)
→ markReactions continues...
Pluggable cross-side notification. Decouples the reactive system from the transport layer.
import { AtomicsNotifier, NoopNotifier } from '@rlabs-inc/signals'
// For cross-thread/cross-language communication
const wakeFlag = new Int32Array(new SharedArrayBuffer(4))
const notifier = new AtomicsNotifier(wakeFlag)
// Multiple synchronous writes → single Atomics.notify via microtask batching
// For testing (no-op)
const silent = new NoopNotifier()Create a deeply reactive proxy (used internally by state()).
const obj = proxy({ a: { b: { c: 1 } } })
effect(() => console.log('c changed:', obj.a.b.c))
effect(() => console.log('a changed:', obj.a))
obj.a.b.c = 2 // Only triggers first effect (fine-grained!)Get the original object from a proxy.
const raw = toRaw(user) // Original non-reactive objectCheck if a value is a reactive proxy.
if (isReactive(value)) {
console.log('This is a proxy')
}Batch multiple signal updates into a single effect run. Essential for performance when doing multiple writes.
const a = signal(1)
const b = signal(2)
effect.sync(() => console.log(a.value + b.value))
// Without batch: effect runs twice
a.value = 10 // Effect runs
b.value = 20 // Effect runs again
// With batch: effect runs once with final values
batch(() => {
a.value = 100
b.value = 200
})
// Effect runs once: 300Read signals without creating dependencies.
effect(() => {
const a = count.value // Creates dependency
const b = untrack(() => other.value) // No dependency
})
// peek is an alias for untrack
const value = peek(() => signal.value)Synchronously flush all pending effects. Useful for testing and ensuring effects have run.
count.value = 5
flushSync() // Effects run NOW, not on next microtask
// Can also wrap a function
flushSync(() => {
count.value = 10
// Effects for this change run before flushSync returns
})Wait for the next update cycle (async). Returns a promise that resolves after all pending effects have run.
count.value = 5
await tick() // Effects have runA 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
users.set('john', { name: 'John!' }) // Triggers above effectA Set with per-item reactivity.
const tags = new ReactiveSet<string>()
effect(() => {
console.log(tags.has('important')) // Only re-runs when 'important' changes
})
tags.add('todo') // Doesn't trigger above effect
tags.add('important') // Triggers above effectA Date with reactive getters/setters.
const date = new ReactiveDate()
effect(() => {
console.log(date.getHours()) // Re-runs when hours change
})
date.setHours(12) // Triggers effectControl when signals trigger updates:
import {
signal, derived,
equals, // Object.is (default for signals)
deepEquals, // Bun.deepEquals (default for derived)
safeEquals, // Handles NaN correctly
shallowEquals, // One level deep comparison
neverEquals, // Always trigger
alwaysEquals, // Never trigger
createEquals // Custom equality
} from '@rlabs-inc/signals'
// signal() uses Object.is (reference equality)
const a = signal(0)
// derived() uses Bun.deepEquals (structural equality)
// Prevents unnecessary propagation when computed values are structurally identical
const items = signal([1, 2, 3])
const doubled = derived(() => items.value.map(x => x * 2))
// Deep equality for signals
const c = signal({ a: 1 }, { equals: deepEquals })
c.value = { a: 1 } // Won't trigger - deeply equal
// Always trigger updates
const f = signal(0, { equals: neverEquals })
f.value = 0 // Still triggers!
// Custom equality
const customEquals = createEquals((a, b) =>
JSON.stringify(a) === JSON.stringify(b)
)
const h = signal([], { equals: customEquals })Default equality by primitive:
| Primitive | Default | Reason |
|---|---|---|
signal() |
Object.is |
User-controlled input - reference equality |
derived() |
deepEquals |
Computed output - structural equality prevents unnecessary work |
linkedSignal() |
deepEquals |
Computed output - structural equality |
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 or use untrack
effect(() => {
if (count.value < 100) {
count.value++
}
})| Feature | @rlabs-inc/signals | Svelte 5 | Vue 3 | Angular | Solid.js |
|---|---|---|---|---|---|
signal() |
signal() |
$state |
ref() |
signal() |
createSignal() |
derived() |
derived() |
$derived |
computed() |
computed() |
createMemo() |
effect() |
effect() |
$effect |
watchEffect() |
effect() |
createEffect() |
effect.sync() |
effect.sync() |
$effect.pre |
- | - | - |
| Deep reactivity | state() |
$state |
reactive() |
- | - |
linkedSignal() |
Yes | - | - | Yes | - |
createSelector() |
Yes | - | - | - | Yes |
effectScope() |
Yes | - | Yes | - | - |
slot() / slotArray() |
Yes | - | - | - | - |
reactiveProps() |
Yes | - | - | - | - |
| Compiler required | No | Yes | No | No | No |
| DOM integration | No | Yes | Yes | Yes | Yes |
For advanced use cases (framework authors, custom reactivity):
import {
source, mutableSource, // Raw signal creation
get, set, // Track/update values
isDirty, // Check if needs update
markReactions, // Notify dependents
createEffect, // Raw effect creation
createDerived, // Raw derived creation
} 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)import type {
// Core types
Signal,
Source,
Reaction,
Derived,
Effect,
Value,
// Public API types
ReadableSignal,
WritableSignal,
DerivedSignal,
DisposeFn,
CleanupFn,
EffectFn,
// Binding types
Binding,
ReadonlyBinding,
// Slot types
Slot,
SlotArray,
// Props types
PropInput,
PropsInput,
ReactiveProps,
// Advanced types
LinkedSignalOptions,
SelectorFn,
EffectScope,
Equals,
} from '@rlabs-inc/signals'MIT