Package Entry Point
| Import | Purpose |
|---|---|
@vielzeug/stateit | Main exports and types |
API At a Glance
| Symbol | Purpose | Execution mode | Common gotcha |
|---|---|---|---|
signal() | Create reactive primitive values | Sync | Write signals inside batch/effect-safe flows |
computed() | Derive memoized values from dependencies | Sync | Avoid side effects inside computed callbacks |
effect() | Run and re-run side effects | Sync | Dispose when no longer needed to prevent memory leaks |
watch() | Subscribe to value changes | Sync | Does not fire immediately unlike effect() |
batch() | Coalesce multiple writes | Sync | Nested batches merge into the outermost |
untrack() | Read without subscribing | Sync | Only suppresses dependency registration, value is still read |
toStore() | Adapt a signal to Svelte's store shape | Sync | Calls the subscriber immediately with the current value |
scope() | Isolated cleanup context | Sync | Must call scope.run() to activate; dispose() is LIFO |
store() | Create object-like state container | Sync | Store is a branded signal; use .patch(), .update(), .reset() |
Signal Primitives
signal
function signal<T>(initial: T, options?: ReactiveOptions<T>): Signal<T>;Creates a reactive atom. Read .value inside an effect or computed to subscribe. Write .value = next to update and notify dependents.
Signals also expose:
peek(): T— read the current value without registering a dependencysubscribe(onStoreChange): () => void— subscribe to future changes without an initial callback, suitable foruseSyncExternalStore()
const count = signal(0);
count.value; // 0 — tracked read
count.value = 1; // notifies dependents
count.value = count.value + 1; // 2Parameters
| Parameter | Type | Description |
|---|---|---|
initial | T | The starting value |
options.equals | EqualityFn<T> | Custom equality; skip notification when true. Default: Object.is |
Returns — Signal<T>
computed
function computed<T>(compute: () => T, options?: ReactiveOptions<T>): ComputedSignal<T>;Creates a lazy derived read-only signal. The compute function runs on the first .value read and again after any dependency changes. Propagation is glitch-free: when a signal that multiple computed nodes share changes, all computed nodes are marked dirty before any subscribed effects run — effects always observe a consistent snapshot.
Call .dispose() to detach from dependencies.
If computed() is created inside an active effect() or scope.run() context, it is automatically registered for cleanup and disposed with that context.
const count = signal(3);
const doubled = computed(() => count.value * 2);
doubled.value; // 6 — compute runs here
count.value = 5;
doubled.value; // 10 — recomputed on read
doubled.dispose(); // stop tracking
// or: using doubled = computed(...) — TC39 using declarationWhen options.equals is provided, downstream subscribers are suppressed if the recomputed value equals the previous value.
Parameters
| Parameter | Type | Description |
|---|---|---|
compute | () => T | Computation function; signals read inside are tracked as dependencies |
options.equals | EqualityFn<T> | Suppress downstream if result is unchanged. Default: Object.is |
Returns — ComputedSignal<T>
effect
function effect(fn: EffectCallback): Subscription;Runs fn immediately and re-runs it whenever any signal read inside it changes. If fn returns a function, that function is called as cleanup before each re-run and on final dispose. Returns a Subscription — dispose is idempotent.
const sub = effect(() => {
document.title = count.value.toString();
return () => {
/* cleanup */
};
});
count.value = 5; // effect re-runs (cleanup called first)
sub.dispose(); // cleanup called, effect removed
sub.dispose(); // no-op — second call is safe
// or: sub() — direct call also disposesParameters
| Parameter | Type | Description |
|---|---|---|
fn | EffectCallback | Runs immediately and on each dependency change; may return a cleanup function |
Returns — Subscription
watch
function watch<T>(source: ReadonlySignal<T>, cb: (value: T, prev: T) => void, options?: WatchOptions<T>): Subscription;
function watch<T>(source: () => T, cb: (value: T, prev: T) => void, options?: WatchOptions<T>): Subscription;Subscribes to value changes on source. Does not fire immediately by default (unlike effect). For derived slices, pass a getter function or wrap the slice with computed().
// Plain watch
const sub = watch(count, (next, prev) => console.log(prev, '→', next));
count.value = 5; // fires
sub.dispose();
// Slice watch — getter source
watch(() => userStore.value.name, (name) => console.log('name:', name));Parameters
| Parameter | Type | Description |
|---|---|---|
source | ReadonlySignal<T> | The signal or store to watch |
cb | (value, prev) => void | Called on each change with new and previous values |
options.immediate | boolean | Fire once immediately on subscription. Default false |
options.equals | EqualityFn<T> | Custom equality for change detection. Default Object.is |
Returns — Subscription
batch
function batch<T>(fn: () => T): T;Runs fn and defers all signal/store notifications until it returns, then flushes once. Nested batch() calls coalesce into the outermost. If fn throws, pending effects are still flushed; the original error takes precedence.
batch(() => {
a.value = 1;
b.value = 2;
// one combined notification after fn returns
});Returns — The return value of fn
untrack
function untrack<T>(fn: () => T): T;Runs fn and returns its result without registering any reactive dependencies. Reads inside are still valid but do not subscribe.
effect(() => {
const x = a.value; // subscribed
const y = untrack(() => b.value); // not subscribed
console.log(x + y);
});Returns — The return value of fn
readonly
function readonly<T>(source: ReadonlySignal<T>): ReadonlySignal<T>;Returns a cached read-only facade over source. The returned object exposes only value, peek(), and subscribe(), so mutator methods from writable sources are hidden at runtime.
const count = signal(0);
const ro = readonly(count);
console.log(ro.value); // 0
count.value = 1;
console.log(ro.value); // 1
// no writable API exposed on the facadeParameters
| Parameter | Type | Description |
|---|---|---|
source | ReadonlySignal<T> | Any signal/store/computed to expose |
Returns — ReadonlySignal<T>
toStore
function toStore<T>(source: ReadonlySignal<T>): { subscribe(run: (value: T) => void): Subscription };Adapts any signal or computed to the Svelte store contract. The subscriber is called immediately with the current value and again on each future change.
const count = signal(0);
const countStore = toStore(count);
const unsubscribe = countStore.subscribe((value) => {
console.log(value);
});
count.value = 1;
unsubscribe();Parameters
| Parameter | Type | Description |
|---|---|---|
source | ReadonlySignal<T> | The signal or computed to wrap |
Returns — An object with a Svelte-compatible subscribe(run) method
onCleanup
function onCleanup(fn: CleanupFn): void;Registers a cleanup function within the currently active effect or scope. When called inside an effect, the cleanup runs before the next re-execution and when the effect is disposed. When called inside scope.run(), the cleanup runs when scope.dispose() is called.
function useInterval(ms: number) {
const id = setInterval(() => console.log('tick'), ms);
onCleanup(() => clearInterval(id)); // works in both effect and scope contexts
}
// Inside an effect:
effect(() => {
useInterval(1000);
});
// Inside a scope:
const s = scope();
s.run(() => {
useInterval(5000);
}); // cleanup runs on s.dispose()isSignal
function isSignal<T = unknown>(value: unknown): value is ReadonlySignal<T>;Type guard that returns true for values created by signal(), computed(), or store(). Uses an internal symbol marker, so arbitrary objects with a value property will not pass.
isSignal(signal(42)); // true
isSignal(computed(() => 1)); // true
isSignal(store({ value: 42 })); // true
isSignal({ value: 42 }); // false — not a real signalStore Functions
store
function store<T extends object>(initial: T): Store<T>;Creates a reactive store for the given object state. Store<T> is a branded signal, so effect(), computed(), watch(), and all other signal primitives that accept ReadonlySignal<T> work with stores directly.
Parameters
| Parameter | Type | Description |
|---|---|---|
initial | T | The starting state (defensively copied; external mutations do not affect reset()) |
Returns — Store<T>
Signal Types
Signal<T>
The base readable/writable reactive primitive.
interface Signal<T> extends ReadonlySignal<T> {
update(fn: (current: T) => T): void;
peek(): T;
subscribe(onStoreChange: () => void): Subscription;
value: T; // notifying setter — write triggers downstream notifications
}ReadonlySignal<T>
interface ReadonlySignal<T> {
peek(): T; // non-tracked read
subscribe(onStoreChange: () => void): Subscription; // future-only subscription
readonly value: T; // tracked getter
}| Member | Description |
|---|---|
value (get) | Returns current value; tracked inside effect/computed |
peek() | Returns current value without tracking |
subscribe() | Registers a change listener without an initial callback |
ComputedSignal<T>
interface ComputedSignal<T> extends ReadonlySignal<T> {
dispose(): void;
[Symbol.dispose](): void;
}Returned by computed(). A read-only signal with an explicit dispose method.
const doubled = computed(() => count.value * 2);
doubled.value; // read
doubled.dispose(); // stop tracking
// or: using doubled = computed(...)Scope
interface Scope {
readonly run: <T>(fn: () => T) => T;
readonly dispose: () => void;
readonly [Symbol.dispose]: () => void;
}Returned by scope(). run(fn) activates the scope so that onCleanup() calls inside fn register on this scope. dispose() runs all registered cleanups in LIFO order and is idempotent.
Subscription
interface Subscription {
(): void; // direct call — disposes the subscription
dispose(): void; // explicit method — equivalent to calling directly
[Symbol.dispose](): void; // TC39 using declarations
}Returned by effect() and watch(). All three forms are equivalent and idempotent.
const sub = effect(() => ...);
sub(); // dispose
sub.dispose(); // dispose (same effect)
// or: using sub = effect(...) — TC39 using declarationCleanupFn
type CleanupFn = () => void;A zero-argument void function. Used for teardown returned from EffectCallback.
EffectCallback
type EffectCallback = () => CleanupFn | void;The callback passed to effect(). May optionally return a CleanupFn that fires before each re-run and on final dispose.
EqualityFn<T>
type EqualityFn<T> = (a: T, b: T) => boolean;A comparator that returns true when a and b should be considered equal (notification suppressed).
ReactiveOptions<T>
type ReactiveOptions<T> = {
equals?: EqualityFn<T>;
};Accepted by signal() and computed().
WatchOptions<T>
type WatchOptions<T> = {
immediate?: boolean;
equals?: EqualityFn<T>;
};| Property | Type | Default | Description |
|---|---|---|---|
immediate | boolean | false | Fire once immediately on subscription; both value and prev are the current value |
equals | EqualityFn<T> | Object.is | Custom equality for change detection |
Store Types
Store<T>
interface Store<T extends object> extends ReadonlySignal<T> {
readonly value: T;
patch(partial: Partial<T>): void;
update(fn: (state: T) => T): void;
reset(): void;
}| Member | Description |
|---|---|
.value (get) | Read current state; tracked inside effect/computed; store is a signal too |
.patch(partial) | Shallow-merge when any provided key changes (Object.is comparison) |
.update(fn) | Receive current state; return value replaces it |
.reset() | Restore the original initial state |
Notification Timing
All signal and store notifications fire synchronously — the subscriber callback runs before the next line after the write.
const s = store({ count: 0 });
const sub = watch(() => s.value.count, (count) => console.log('changed:', count));
s.patch({ count: 1 });
// 'changed: 1' has already been logged hereTo coalesce multiple writes into a single notification, use the top-level batch():
import { batch } from '@vielzeug/stateit';
batch(() => {
s.patch({ count: 1 });
s.patch({ count: 2 });
s.patch({ count: 3 });
});
// One notification fires after the batch with the final state: { count: 3 }Nested batch() calls merge into the outermost — only one flush occurs.