Deterministic signals and reactive collections for JavaScript.
tracer gives you small, explicit primitives for storing values, deriving values, subscribing to changes, batching updates, and working with patch-producing collections.
This package is ESM-only.
import { signal, batch, overridable } from 'tracer'import { signal } from 'tracer'
const count = signal(0)
const unsubscribe = count.subscribe(change => {
console.log(change.kind, change.nextValue)
})
count.setValue(1)
count.setValue(value => value + 1)
unsubscribe()A stored signal exposes:
getValue(): reads the current valuesetValue(nextOrUpdater): updates the value and returnstrueif it changedsubscribe(cb): subscribes to changes and returns an unsubscribe function
Subscribers are called immediately with an init change.
{
kind: 'init' | 'update',
nextValue: any,
previousValue: any,
meta?: any
}Pass a function to signal() to create a readonly derived signal.
const firstName = signal('Ada')
const lastName = signal('Lovelace')
const fullName = signal($ => `${$(firstName)} ${$(lastName)}`)
fullName.getValue() // 'Ada Lovelace'The $ function tracks dependencies. A derived signal only depends on signals you explicitly pass to $.
const useNickname = signal(false)
const name = signal('Ada')
const nickname = signal('Enchantress of Numbers')
const displayName = signal($ => {
return $(useNickname) ? $(nickname) : $(name)
})Derived signals expose:
getValue()subscribe(cb)
They do not expose setValue().
Use batch(fn) to coalesce multiple updates into one notification per affected signal.
const count = signal(0)
count.subscribe(change => {
if (change.kind === 'init') return
console.log(change.previousValue, change.nextValue)
})
batch(() => {
count.setValue(1)
count.setValue(2)
count.setValue(3)
})
// Logs once:
// 0 3Use signal.array(initialArray) for an array signal with explicit mutation APIs.
const items = signal.array(['a', 'b'])
items.getValue() // frozen ['a', 'b']
items.index.length.subscribe(change => {
if (change.kind === 'init') return
console.log('length:', change.nextValue)
})
items.mutate(array => {
array.push('c')
array.set(0, 'A')
})Array signals expose:
getValue()setValue(nextArrayOrUpdater)mutate(fn)index.length: signal of the array length
Array mutators:
push(...items)pop()unshift(...items)shift()splice(start, deleteCount, ...items)set(index, value)
Pass a function to signal.array() to create a readonly derived array signal.
const first = signal('Ada')
const second = signal('Grace')
const names = signal.array($ => [
$(first),
$(second)
])
names.getValue() // frozen ['Ada', 'Grace']
names.index.length.getValue() // 2Derived arrays expose:
getValue()subscribe(cb)index.length
They do not expose setValue() or mutate().
Use signal.object(initialObject) for a plain-object signal.
const person = signal.object({
name: 'Ada',
age: 36
})
person.mutate(object => {
object.set('name', 'Grace')
object.assign({ role: 'programmer' })
})Object signals expose:
getValue()setValue(nextObjectOrUpdater)mutate(fn)index.keys: signal of ordered object keysindex.size: signal of key count
Object mutators:
has(key)get(key)set(key, value)delete(key)assign(partial)
Pass a function to signal.object() to create a readonly derived object signal.
const name = signal('Graham')
const age = signal(41)
const person = signal.object($ => ({
name: $(name),
age: $(age)
}))
person.getValue() // frozen { name: 'Graham', age: 41 }
person.index.keys.getValue() // frozen ['name', 'age']
person.index.size.getValue() // 2Derived objects expose:
getValue()subscribe(cb)index.keysindex.size
They do not expose setValue() or mutate().
Use signal.map(initialEntries?) for a reactive Map-like signal.
const users = signal.map([
['ada', { name: 'Ada' }]
])
users.has('ada') // true
users.get('ada') // { name: 'Ada' }
users.size // 1
users.mutate(map => {
map.set('grace', { name: 'Grace' })
})Map signals expose:
getValue(): readonly map viewsetValue(nextMapOrEntriesOrUpdater)mutate(fn)has(key)get(key)sizeindex.keys: signal of ordered keysindex.size: signal of map sizekey(k): stable signal for one key
key(k) emits objects shaped like:
{ present: boolean, value: any }const ada = users.key('ada')
ada.subscribe(change => {
console.log(change.nextValue.present, change.nextValue.value)
})Map mutators:
has(key)get(key)set(key, value)delete(key)clear()keys()values()entries()
Pass a function to signal.map() to create a readonly derived map signal.
const name = signal('Graham')
const age = signal(41)
const person = signal.map($ => [
['name', $(name)],
['age', $(age)]
])
person.get('name') // 'Graham'
person.size // 2
person.index.keys.getValue() // frozen ['name', 'age']Derived maps expose:
getValue()subscribe(cb)has(key)get(key)sizeindex.keysindex.sizekey(k)
They do not expose setValue() or mutate().
Use signal.set(initialValues?) for a reactive Set-like signal.
const selectedIds = signal.set(['a'])
selectedIds.has('a') // true
selectedIds.size // 1
selectedIds.mutate(set => {
set.add('b')
set.delete('a')
})Set signals expose:
getValue(): readonly set viewsetValue(nextSetOrValuesOrUpdater)mutate(fn)has(value)sizeindex.values: signal of ordered set valuesindex.size: signal of set sizevalue(v): stable signal for one value's membership
value(v) emits objects shaped like:
{ present: boolean }Set mutators:
has(value)add(value)delete(value)clear()keys()values()entries()
Pass a function to signal.set() to create a readonly derived set signal.
const first = signal('Ada')
const second = signal('Grace')
const names = signal.set($ => [
$(first),
$(second)
])
names.has('Ada') // true
names.size // 2
names.index.values.getValue() // frozen ['Ada', 'Grace']Derived sets expose:
getValue()subscribe(cb)has(value)sizeindex.valuesindex.sizevalue(v)
They do not expose setValue() or mutate().
Stored collections publish patch bundles when they change.
const state = signal.object({ name: 'Ada' })
const bundle = state.mutate(object => {
object.set('name', 'Grace')
})
console.log(bundle)A patch bundle contains:
{
patches: Patch[],
inversePatches: Patch[]
}The same bundle is also available as change.meta for subscribers.
state.subscribe(change => {
if (change.kind === 'init') return
console.log(change.meta.patches)
console.log(change.meta.inversePatches)
})Patch bundles are frozen and can be used for undo/redo, persistence, debugging, or synchronization.
overridable(baseSignal) creates a signal wrapper that follows another signal until you explicitly override it.
const serverValue = signal('light')
const localValue = overridable(serverValue)
localValue.getValue() // 'light'
localValue.setValue('dark')
localValue.getValue() // 'dark'
localValue.isOverridden // true
localValue.clear()
localValue.getValue() // follows serverValue againOverridable signals expose:
getValue()setValue(nextOrUpdater)clear()isOverriddensubscribe(cb)
signal(value)
signal($ => value)
signal.array(array)
signal.array($ => array)
signal.object(object)
signal.object($ => object)
signal.map(entriesOrMap?)
signal.map($ => entriesOrMap)
signal.set(valuesOrSet?)
signal.set($ => valuesOrSet)
batch(fn)
overridable(signal)- Derived signals and derived collections are readonly.
- Collection signals use explicit mutation APIs instead of proxies.
- Collection values and patch bundles are frozen.
subscribe(cb)always sends an initialinitchange.setValue()returnsfalsewhen the value does not change.