Timing and propagation
Shape when a signal's read value updates without changing how you write to it. debounced waits out a burst, throttled rate-limits it, and until turns a signal condition into a promise.
@mmstack/primitivesnpm
#debounced and debounce
A debounced signal holds its read value until ms milliseconds after the last write. You still call set and update normally. Dependents just see the value settle instead of updating on every keystroke.
There are two entry points. debounced(initial, { ms }) creates a new writable already debounced. debounce(sourceSignal, { ms }) wraps an existing signal. Both give you an .original signal for the immediate, un-debounced value.
import { debounce, debounced } from '@mmstack/primitives';
// create a new writable, already debounced
const query = debounced('', { ms: 300 });
// or wrap an existing signal
const wrapped = debounce(signal(''), { ms: 300 });
effect(() => search(query())); // fires 300ms after typing stops
effect(() => echo(query.original())); // fires immediately Read query() where you want the delayed value (a search request) and query.original() where you want the live one (an echo in the input).
#throttled and throttle
Throttling caps propagation to at most one value per ms window. Same two-function shape as debounce: throttled(initial, { ms }) creates one, throttle(sourceSignal, { ms }) wraps one, and .original is the escape hatch to the immediate value.
import { throttle, throttled } from '@mmstack/primitives';
// trailing edge only (default): latest write lands when the window closes
const scroll = throttled(0, { ms: 200 });
// wrap an existing signal instead of creating one
const wrapped = throttle(signal(0), { ms: 200 });
// leading + trailing, lodash-style
const both = throttled(0, { ms: 200, leading: true, trailing: true }); The default is trailing-edge only, so the latest write in a window lands when the window closes. Pass leading: true to emit the first write immediately, and trailing: false to suppress the closing fire. There is also a flush() on the throttled signal that emits the current value now and clears the open window, for a terminal update that should not wait out the cooldown.
#until
until resolves a promise once a signal value satisfies a predicate. It handles type-narrowing predicates, an optional timeout, and auto-cancellation when the calling context is destroyed.
import { until } from '@mmstack/primitives';
const event = signal<Event | null>(null);
// narrowing predicate: click is typed MouseEvent
const click = await until(event, (e): e is MouseEvent => e instanceof MouseEvent);
// with a timeout
await until(progress, (p) => p === 100, { timeout: 5_000 }); A narrowing predicate carries its type through, so the resolved value is already the narrowed type with no extra cast. It resolves immediately if the current value already passes, and it needs an injection context (or an explicit injector option) to run its watcher.
This primitive is useful for async interop, but primary I find myself reaching for it in testing, so that i can await/assert the exact signal change moment instead of working with fixture.whenStable & similar.