@rrjs/signals
v0.2.2
Published
Fine-grained reactive primitives: createSignal, computed, effect, batch
Maintainers
Readme
@rrjs/signals
The reactive primitive layer. Used by every other package in the Reactive React project. Can be used directly when you need fine-grained reactivity without a UI library.
Install
npm install @rrjs/signalsQuick start
import { createSignal, effect, computed } from '@rrjs/signals'
const [count, setCount] = createSignal(0)
const doubled = computed(() => count() * 2)
effect(() => {
console.log(`count=${count()}, doubled=${doubled()}`)
})
// → "count=0, doubled=0"
setCount(5)
// → "count=5, doubled=10"API
createSignal(initial)
Returns [getter, setter]. Reading the getter inside an effect or computed subscribes that effect to changes.
const [name, setName] = createSignal('Alice')
name() // 'Alice'
setName('Bob')
name() // 'Bob'The setter accepts a functional updater:
setName(prev => prev.toUpperCase())Same-value writes use Object.is and skip notification.
computed(fn)
A derived signal. Returns a getter. The function re-runs only when its tracked dependencies change.
const [a, setA] = createSignal(2)
const [b, setB] = createSignal(3)
const sum = computed(() => a() + b())
sum() // 5
setA(10)
sum() // 13Handles diamond dependencies correctly — a downstream computed fires exactly once when a shared upstream signal changes.
effect(fn)
Runs fn immediately, then re-runs it whenever any signal read inside changes.
const dispose = effect(() => {
console.log(count())
return () => console.log('cleanup') // optional cleanup
})
dispose() // stop re-running, run final cleanupReturns a dispose function. Cleanups run in reverse order: previous-cleanup → next-effect-run.
batch(fn)
Defers effect flushing until fn returns. Multiple signal writes inside batch produce one flush.
batch(() => {
setA(1)
setB(2)
setC(3)
})
// Effects depending on a, b, c run once, not three timesuntrack(fn)
Runs fn with no observer current, so signal reads inside it do not subscribe the
surrounding effect. Returns whatever fn returns.
effect(() => {
console.log(a()) // tracked — this effect re-runs when a changes
const snapshot = untrack(() => b()) // read once, no subscription to b
console.log(snapshot)
})Effects created inside untrack still track their own reads normally — only the
observer that was current on entry is suppressed, and it is restored on exit.
The renderer uses this when building keyed list rows: creating a row reads that row's
data, and without untrack those reads would subscribe the list effect to every row, so
touching one row would re-reconcile the whole list.
Edge cases
- Async tracking: signals read inside
setTimeout,await, orrequestAnimationFramecallbacks do not track. The observer stack is synchronous. - Cycles: writing to a signal you're currently tracking is allowed but may cause unintended re-runs. The library does not detect cycles automatically.
- Same-value writes:
setCount(5); setCount(5)only notifies once. UsesObject.is, soNaNcompared with itself is equal, and+0and-0are not.
License
MIT
