@spearwolf/signalize — Quick Reference
Synchronous, fine-grained reactivity. ESM-only, sideEffects: false, targets ES2023, runs on Node >=24.13 or any modern browser. Built on @spearwolf/eventize (peer dep). Fully typed for TypeScript.
Mental model
- Four primitives: Signal (reactive value) → Effect (auto-rerun) → Memo (cached derived signal) → Link (one-way data flow). SignalGroup owns lifecycles.
- Everything propagates synchronously, inline.
signal.set(x)runs every dependent effect before returning. No scheduler, no microtask queue. - Effects subscribe on read (
signal.get()) within their callback; deps are recomputed every run unless static. - Memos are signals driven internally by a high-priority (
1000) effect, so they resolve before normal effects.
Two entry points
import {/* core */} from '@spearwolf/signalize';
import {signal, memo} from '@spearwolf/signalize/decorators';
Decorators are TC39 standard form (no experimentalDecorators). Use the accessor keyword.
Public API surface
// --- runtime values ---
// signals
createSignal, destroySignal, isSignal, muteSignal, unmuteSignal,
getSignalsCount, touch, value
// effects
createEffect, getEffectsCount, onCreateEffect, onDestroyEffect
// memos
createMemo
// links
link, unlink, getLinksCount
// context modes
batch, beQuiet, isQuiet, hibernate
// lifecycle / collections
SignalGroup, SignalAutoMap
// host-object signals
findObjectSignalByName, findObjectSignals, findObjectSignalNames, destroyObjectSignals
// classes (exported for `instanceof` and as types)
Signal, Effect
// --- type-only re-exports (no runtime value) ---
// SignalReader, SignalWriter, SignalLike, SignalParams, SignalWriterParams,
// EffectOptions, EffectCallback, CreateMemoOptions, LinkOptions,
// SignalLink, ValueCallback, SignalAutoMapKeyType,
// CompareFunc, BeforeReadFunc, VoidFunc, ValueChangedCallback
Signals
const c = createSignal(0, {
lazy: false, // true → initial is a factory, evaluated on first read
compare: (a, b) => a===b, // custom equality (default ===)
beforeRead: () => {}, // hook on tracked reads only (NOT on .value)
attach: obj, // SignalGroup lifecycle
});
c.get(); // tracked read (registers dep when inside an effect)
c.value; // untracked read
c.set(v); c.value = v;
c.set(v, {touch: true}); // notify even if equal
c.set(fn, {lazy: true}); // factory, evaluated on next read
c.touch(); c.destroy(); c.muted = true;
const off = c.onChange(v => …); // returns unsubscribe
Helpers: value(c) / value([obj,'prop']) (untracked), touch(c) / touch([obj,'prop']) (notify), isSignal(x), muteSignal(c), unmuteSignal(c), destroySignal(...sigs).
Effects
createEffect(() => {
use(c.get());
return () => cleanup();
}, {
autorun: true, // false → manual eff.run()
dependencies: [c], // STATIC deps → disables auto-tracking; does NOT autorun
priority: 0, // higher first
attach: obj,
});
createEffect(cb, [c]); // shorthand → static deps
createEffect(cb, ['name'], {attach: obj}); // names resolved against group
const eff = createEffect(cb, {autorun: false});
eff.run(); // runs only if a tracked dep changed since last run
eff.destroy();
getEffectsCount(), onCreateEffect(cb) → unsub, onDestroyEffect(cb) → unsub.
Memos
const m = createMemo(() => a.get() * 2, {
lazy: false, // true → recompute on read; effects DO NOT re-run on dep change
priority: 1000,
attach: obj,
name: 'm',
});
m(); // SignalReader<T>
Links
const con = link(src, target, {attach: obj}); // target: signal | (v) => void
unlink(src, target); unlink(src); // drop one or all
getLinksCount(); getLinksCount(src);
con.lastValue; con.isMuted; con.isDestroyed;
con.mute(); con.unmute(); con.toggleMute();
con.touch(); con.destroy(); con.attach(obj);
await con.nextValue();
for await (const v of con.asyncValues((v,i) => i>=5)) {/* … */}
Link emits eventize events on itself: 'value', 'mute', 'unmute', 'destroy'.
Context modes
batch(() => { a.set(1); b.set(2); }); // dedup + flush in priority order; HINT not guarantee
beQuiet(() => a.get()); // reads untracked, writes silent (counter, nests)
hibernate(() => { /* outer ctx suspended */ }); // batches/quiet/effect-stack saved & restored
isQuiet();
hibernate flushes any active outer batch before running its callback so queued effects aren't lost.
SignalGroup
const g = SignalGroup.findOrCreate(obj); // throws on null; returns same instance for the same obj
SignalGroup.get(obj); // existing or undefined
SignalGroup.delete(obj); // clear & remove (preferred destructor)
SignalGroup.clear(); // global
g.attachSignal(s); g.attachSignalByName('n', s); g.detachSignal(s);
g.signal('n'); // walks parent chain
g.hasSignal('n');
g.attachEffect(e); g.runEffects();
g.attachLink(l); g.detachLink(l);
g.attachGroup(child); g.detachGroup(child);
g.clear();
Registry is WeakMap<object, SignalGroup>; back-pointer is WeakRef. Attaching a group to a user object does not keep it alive.
SignalAutoMap
const m = new SignalAutoMap();
const m2 = SignalAutoMap.fromProps({a:1, b:2}, ['a']);
m.get('k'); // auto-creates Signal<undefined>
m.has('k');
m.update(new Map([['k','v']])); // batched
m.updateFromProps(obj, ['k']); // batched
for (const k of m.keys()){} for (const s of m.signals()){} for (const [k,s] of m.entries()){}
m.clear();
Object signals (used by decorators)
findObjectSignalByName(obj, 'prop'); // Signal<T> | undefined
findObjectSignals(obj); // Signal[] | undefined
findObjectSignalNames(obj); // (string|symbol)[] | undefined
destroyObjectSignals(obj1, obj2); // signals only — for full cleanup use SignalGroup.delete(obj)
Decorators
class Foo {
@signal({ // accessor REQUIRED
name: 'count', // override registered name
readAsValue: false, // true → property getter is .value (untracked)
compare: (a,b)=>a===b,
beforeRead: () => {},
attach: something, // override default group (the instance)
}) accessor count = 0;
@memo({name:'doubled'}) // ALWAYS lazy; attached to instance group
doubled() { return this.count * 2; }
}
// Cleanup: SignalGroup.delete(instance) -- or destroyObjectSignals(instance) for signals-only
Each instance gets its own per-property signal. @memo is always lazy — for an eager class memo use createMemo() directly.
⚠️ Quirks & pitfalls (LLMs commonly get these wrong)
No React-style updater function.
signal.set((v) => v+1)stores the function as the value, it is not invoked. TypeScript blocks this for typed code;any/untyped paths slip through. Usesignal.set(signal.value + 1).signal.get()tracks,signal.valuedoes not. Top-level reactivity bug source: writingc.valueinside an effect when you meantc.get()results in an effect that never re-runs.Static deps disable autorun AND auto-tracking.
createEffect(cb, [a, b])does NOT run on creation — call.run()once if you need the initial pass. Signals read inside the callback are NOT subscribed; only[a,b]trigger reruns. The same is true for{dependencies: [...]}.Lazy is not sticky.
createSignal(fn, {lazy:true})is lazy until first read. After a non-lazyset(v)the signal stays non-lazy. Pass{lazy:true}again to re-lazy. Likewise, callingset(fn)(without{lazy:true}) stores the function as the value.createSignal(otherSignal)is a passthrough. It returns the existing signal — no new signal, no counter increment. Useful for "value or signal" helpers; do NOT assume it cloned.Memo eager vs lazy changes downstream behaviour. Default
lazy:falsemakes the memo a computed signal: dependent effects re-run on dep change. Withlazy:true, dependent effects do NOT re-run on dep change; the memo is only recomputed on read.@memo()is always lazy.Synchronous self-write recursion is bounded. If an effect callback writes to a signal it depends on,
run()re-enters synchronously. Capped atEffectImpl.maxDepth = 256; beyond that throws a descriptiveError(not a stack overflow). Prefer breaking the cycle (beQuietfor self-writes, conditional guard, split effect). TuneEffectImpl.maxDepth = Nonly when intentional.signalReader(callback)is deprecated.sig.get(cb)creates an internal effect with no unsubscribe handle — only destroying the signal cleans it up. Emits a once-per-processconsole.warn. Usesig.onChange(cb)(returns an unsubscribe).batch()is a HINT, not a guarantee. Most flushes are deduplicated and priority-ordered, but internal consistency rules can still cause partial propagation. Don't rely on "exactly one effect run per batch" as a correctness invariant.beforeReadonly fires on tracked reads. Includingsig.get()and the deprecatedsig.get(cb)form..value/value(sig)skip it. Don't usebeforeReadfor invariants you need on every observation.set(v, {touch:true})is suppressed on muted/destroyed signals. Same forsignal.touch(). If you need to force-emit on a muted signal, unmute first.Nested effects are recreated on every parent rerun. Order on a re-run: parent's own cleanup → child effects destroyed (each child's cleanup runs as part of its destroy) → parent callback re-executes → fresh inner effects created. Don't capture the inner
Effecthandle in long-lived state.Dynamic deps can shrink between runs. Signals read in run N but not in run N+1 are unsubscribed at the end of run N+1. Conditional
if (a.get()) b.get()is fine — that's the whole point of dynamic tracking.SignalGroup.findOrCreate(group)returns the group itself. Passing an existingSignalGroupis a no-op identity.findOrCreate(null)throws.SignalGroup.delete(obj)≠g.clear(). They both clear; the static form also looks the group up by the user object first. The instance methoddestroy()is deprecated and warns — useclear().Same
(source, target)pair?link()returns the existing link. It is deduplicated; do not assume each call creates a new SignalLink. The link is auto-destroyed when source OR (signal-)target is destroyed.SignalAutoMapretains destroyed signals. If you calldestroySignal()on an entry, the map keeps it: reads return last value, writes are silent no-ops. Prefermap.clear()or attach signals to a SignalGroup.Decorator memos have their own group.
@signaland@memoregister againstSignalGroup.findOrCreate(this). Cleanup withSignalGroup.delete(this)(full) ordestroyObjectSignals(this)(signals only — leaves attached effects/links alive).In-source imports use
.jsextension (NodeNext resolution):import {x} from './foo.js'even when the source isfoo.ts. This affects code you write inside the package; consumers don't care.No async by default. Effect callbacks may be async, and a returned cleanup will be called when the promise settles, but propagation itself is synchronous. There is no
microtaskdebounce — write your own if needed.
Idiomatic patterns
Lifecycle-bundled component
class Player {
health = createSignal(100, {attach: this});
pos = createSignal({x:0,y:0}, {attach: this});
constructor() {
createEffect(() => render(this.pos.get()), {attach: this});
link(this.health, (v) => v <= 0 && this.die(), {attach: this});
}
destroy() { SignalGroup.delete(this); } // tears down signals + effects + links
}
Frame-paced effect
const eff = createEffect(render, {autorun: false});
const tick = () => { updateState(); eff.run(); requestAnimationFrame(tick); };
requestAnimationFrame(tick);
Computed in a chain
const items = createSignal<Item[]>([]);
const visible = createMemo(() => items.get().filter(x => x.visible)); // eager
const count = createMemo(() => visible().length); // eager, depends on memo
createEffect(() => render(count())); // re-runs when count() changes
Decorator class with eager memo
class Cart {
@signal() accessor items: Item[] = [];
// Eager — must use createMemo because @memo is always lazy
total = createMemo(() => this.items.reduce((s, x) => s + x.price, 0), {attach: this});
destroy() { SignalGroup.delete(this); }
}
Leak check in tests
const baseline = [getSignalsCount(), getEffectsCount(), getLinksCount()];
// run scenario, destroy/clear
expect([getSignalsCount(), getEffectsCount(), getLinksCount()]).toEqual(baseline);
When NOT to reach for signalize
- You need cross-process or async-by-default state → use a real state store / message bus.
- You need built-in time travel / undo / devtools → pick a state library that ships them.
- One-off "callback when X changes" with no graph of derivations →
signal.onChange(cb)is fine, but consider plain eventize.
Anti-patterns to refuse / rewrite
signal.set(prev => prev + 1)— not an updater. Rewrite assignal.set(signal.value + 1).signal.get(callback)for subscriptions — deprecated. Rewrite assignal.onChange(callback).createSignal(...).value = …immediately followed bycreateEffect(() => …signal.value…)— effect won't track. Usesignal.get().createEffect(cb, [a])without a follow-up.run()when an initial pass is needed.- Manually re-implementing memo with
createEffectwriting to a signal — usecreateMemo(priority + cache for free). tsconfig.jsonwithexperimentalDecorators: truefor the@signal/@memodecorators — they require the standard form.- Forgetting to destroy effects/links — track via
SignalGroupfrom the start, not retroactively.
Source: spearwolf/signalize — distributed by TomeVault.