New to Stateit?
Start with the Overview for a quick introduction and installation, then come back here for in-depth usage patterns.
Signals
A signal is the fundamental reactive primitive. It holds a single value and notifies dependents when that value changes.
Creating a Signal
const count = signal(0);
const name = signal('Alice');
const items = signal<string[]>([]);Reading and Writing
count.value; // read — tracked inside effect/computed
count.value = 42; // write — notifies all dependents
count.peek(); // read without registering a subscription
untrack(() => count.value); // equivalent escape hatch for arbitrary readsExternal Store Interop
Every signal exposes a small external-store interface:
const unsubscribe = count.subscribe(() => {
console.log('changed:', count.value);
});
count.value = 1;
unsubscribe();subscribe() does not fire immediately on subscription. It only fires after the value changes, which matches React's useSyncExternalStore() contract.
Effects
effect() runs a function immediately and re-runs it whenever any signal read inside it changes. Returns a Subscription handle.
const count = signal(0);
const sub = effect(() => {
console.log('count is:', count.value);
});
// → logs "count is: 0" immediately
count.value = 1; // → logs "count is: 1"
count.value = 2; // → logs "count is: 2"
sub.dispose(); // dispose — no more runs
// or: sub() — calling directly also disposes
// or: using sub = effect(...) — TC39 using declarationEffect Cleanup
Return a cleanup function from the effect callback; it runs before the next re-execution and when the effect is disposed:
const sub = effect(() => {
const id = setInterval(() => console.log('tick'), 1000);
return () => clearInterval(id); // cleanup on next run or dispose
});onCleanup
Register teardown from inside nested helpers without using the return value:
function useInterval(ms: number) {
const id = setInterval(() => console.log('tick'), ms);
onCleanup(() => clearInterval(id)); // registers cleanup in the current effect
}
const sub = effect(() => {
useInterval(1000); // cleanup registered automatically
});Stateit includes a built-in loop guard (100 iterations) to protect against accidental self-triggering effect cycles.
untrack
Reads signals inside an effect without creating reactive subscriptions:
const a = signal(1);
const b = signal(2);
effect(() => {
// only subscribed to `a`; changes to `b` will not re-run this effect
const sum = a.value + untrack(() => b.value);
console.log('sum:', sum);
});Computed
computed() creates a derived read-only signal whose value is automatically recomputed when its dependencies change.
const count = signal(3);
const doubled = computed(() => count.value * 2);
console.log(doubled.value); // 6
count.value = 10;
console.log(doubled.value); // 20Call .dispose() when the computed is no longer needed to detach it from its dependencies and stop recomputation:
doubled.dispose();
// or: using doubled = computed(...) — TC39 using declarationAutomatic Disposal Inside Effects
When computed() is called inside an effect(), the computed signal is automatically disposed when the effect cleans up. This prevents memory leaks from derived computations that only exist within the effect scope:
effect(() => {
// This computed is automatically disposed when the effect is disposed
const derived = computed(() => expensiveCalc(source.value));
doSomething(derived.value);
});This behavior is an ergonomic convenience and works because computed() detects the active effect scope and registers itself for automatic cleanup.
peek
Computed signals also support .peek() for non-tracked reads:
const total = computed(() => subtotal.value + tax.value);
effect(() => {
console.log('tracked total', total.value);
});
const snapshot = total.peek();Chaining Computeds
const a = signal(2);
const b = computed(() => a.value * 3); // 6
const c = computed(() => b.value + 1); // 7
a.value = 4;
console.log(c.value); // 13watch (Signals)
watch() is an explicit subscription that fires only when the signal's value changes — it does not run immediately like effect(). Returns a Subscription.
const count = signal(0);
const sub = watch(count, (next, prev) => {
console.log(prev, '→', next);
});
count.value = 1; // → logs "0 → 1"
sub.dispose();
// or: sub() — calling directly also disposesOptions
// Fire once immediately on subscription
watch(count, (v) => console.log(v), { immediate: true });
// Custom equality — suppress callback when result is considered equal
watch(list, (v) => renderList(v), { equals: (a, b) => a.length === b.length });Watching a Slice
Use a getter function when you want to watch a derived slice directly:
watch(() => userStore.value.name, (name, prevName) => {
console.log('name:', prevName, '→', name);
});
// For reusable derived values, keep using computed()
const nameSignal = computed(() => userStore.value.name);
watch(nameSignal, (name, prev) => console.log('name:', prev, '→', name));
nameSignal.dispose();batch (Signals)
batch() defers all signal notifications until the callback returns, then flushes once. Nested batches coalesce into the outermost.
const a = signal(0);
const b = signal(0);
let fires = 0;
effect(() => {
a.value;
b.value;
fires++;
});
// fires is 1 (initial run)
batch(() => {
a.value = 1;
b.value = 2;
});
// fires is 2 (one flush for both)If the callback throws, pending notifications are still flushed after the error; the original error is re-thrown with the flush errors suppressed.
Framework Integration
import { useSyncExternalStore } from 'react';
import { signal, computed, effect, type ReadonlySignal } from '@vielzeug/stateit';
// Generic hook — works with any signal or computed
function useSignalValue<T>(source: ReadonlySignal<T>): T {
return useSyncExternalStore(source.subscribe, () => source.value);
}
// Usage in a component
const count = signal(0);
const doubled = computed(() => count.value * 2);
function Counter() {
const value = useSignalValue(count);
const doubledValue = useSignalValue(doubled);
return (
<div>
<p>{value} × 2 = {doubledValue}</p>
<button onClick={() => count.value++}>Increment</button>
</div>
);
}import { customRef, onScopeDispose } from 'vue';
import { signal, computed, watch, type ReadonlySignal, type Signal } from '@vielzeug/stateit';
// Composable for read/write signals
function useSignal<T>(source: Signal<T>) {
return customRef<T>((track, trigger) => ({
get() {
track();
return source.value;
},
set(value) {
source.value = value;
trigger();
},
}));
}
// Composable for read-only signals and computeds
function useSignalValue<T>(source: ReadonlySignal<T>) {
const stop = watch(source, () => {}, { immediate: true });
onScopeDispose(() => stop.dispose());
return customRef<T>((track) => ({
get() {
track();
return source.value;
},
set(value) {
void value;
},
}));
}<script lang="ts">
import { signal, computed, toStore } from '@vielzeug/stateit';
// toStore() adapts any signal to the Svelte store contract
const count = signal(0);
const doubled = computed(() => count.value * 2);
const countStore = toStore(count);
const doubledStore = toStore(doubled);
// Use $countStore and $doubledStore in the template
</script>
<p>{$countStore} × 2 = {$doubledStore}</p>
<button on:click={() => count.value++}>Increment</button>Pitfalls
- Forgetting cleanup/dispose calls can leak listeners or stale state.
- Skipping explicit typing can hide integration issues until runtime.
- Not handling error branches makes examples harder to adapt safely.
scope
scope() creates an isolated cleanup context that is not tied to any reactive effect. Use it when you want to collect teardown callbacks and release them all at once — without needing an effect or a component lifecycle hook.
import { scope, onCleanup } from '@vielzeug/stateit';
const s = scope();
s.run(() => {
const id = setInterval(() => tick(), 1000);
onCleanup(() => clearInterval(id));
const ws = new WebSocket('wss://example.com');
onCleanup(() => ws.close());
});
// Later — tears down all cleanups in LIFO order:
s.dispose();scope.run() can be called multiple times to incrementally register cleanups into the same scope. The using declaration auto-disposes at block end:
{
using s = scope();
s.run(() => {
onCleanup(() => console.log('cleaned up'));
});
} // ← scope.dispose() called here automaticallyInside craftit components, scope() is available via @vielzeug/craftit and is useful for managing sub-scoped cleanup (e.g., an animation controller or WebSocket owned by one part of a component):
import { scope, onCleanup, effect } from '@vielzeug/craftit';
define('my-component', {
setup() {
const animScope = scope();
onCleanup(() => animScope.dispose()); // tie sub-scope to component lifetime
onMounted(() => {
animScope.run(() => {
const raf = requestAnimationFrame(animate);
onCleanup(() => cancelAnimationFrame(raf));
});
});
return () => html`...`;
},
});Stores
A Store<T> adds structured state helpers on top of a .value getter. Every signal primitive (computed, effect, watch, batch, untrack) works on stores directly.
Creating a Store
import { store } from '@vielzeug/stateit';
const s = store({ count: 0, user: null as User | null });Reading State
const state = s.value; // { count: 0, user: null }
const count = s.value.count; // 0.value is a synchronous getter — no method call needed.
Writing State
Partial Patch
Shallow-merges the patch into the current state:
s.patch({ count: 1 });
// Equivalent to: { ...current, count: 1 }Updater Function
Receives the current state; return value replaces it:
s.update((current) => ({ ...current, count: current.count + 1 }));patch() and update() are no-ops when the resulting state passes the equals check configured on the store (default: Object.is).
Resetting State
// Restore to the state passed to store()
s.reset();reset() triggers a notification if the state actually changes. The initial state is defensively copied at construction time — external mutations to the original object cannot corrupt reset().
Derived Slices
Use computed() to derive a signal from a slice of the store's state:
const countSignal = computed(() => s.value.count);
console.log(countSignal.value); // 0
// Compose with watch() to react to slice changes only
const sub = watch(countSignal, (count, prev) => {
console.log('count changed:', prev, '→', count);
});
// Clean up when done
sub.dispose();
countSignal.dispose();Pass a custom equals option for arrays and objects to avoid re-rendering when contents haven't changed:
const items = computed(() => s.value.items, { equals: (a, b) => a.length === b.length });Watching State
Full-State Watch
// Does not fire immediately — use { immediate: true } to opt in
const sub = watch(s, (curr, prev) => {
console.log('state changed', curr);
});
sub.dispose(); // stop receiving updatesWhen immediate: true, the listener fires once synchronously on subscription with both curr and prev set to the current value:
watch(
s,
(curr, prev) => {
console.log('initial or changed:', curr.count);
},
{ immediate: true },
);To auto-stop after the first change, dispose manually inside the callback:
const stop = watch(s, (curr) => {
console.log('first change:', curr);
stop();
});Slice Watch
Use a getter source to watch a slice — only fires when the derived value changes:
// Only fires when `count` changes — unrelated state changes are ignored
watch(() => s.value.count, (count, prev) => console.log('count changed to', count));
// With computed() for a reusable or shareable slice signal
const countSignal = computed(() => s.value.count);
watch(countSignal, (count, prev) => console.log('count changed to', count), {
equals: (a, b) => a === b,
});Batching
batch() groups multiple writes into a single notification:
import { batch } from '@vielzeug/stateit';
const result = batch(() => {
s.patch({ firstName: 'Alice' });
s.patch({ lastName: 'Smith' });
s.patch({ age: 30 });
return 'profile updated';
});
// One notification for all three patch() calls
// result === 'profile updated'Nested batch() calls merge into the outermost — only one notification fires when the outermost batch completes.
Narrowing to Read-Only
To expose a store at API boundaries where consumers should observe but not mutate, wrap it with readonly():
import { readonly, store } from '@vielzeug/stateit';
import type { ReadonlySignal } from '@vielzeug/stateit';
type CounterService = {
state: ReadonlySignal<{ count: number }>;
increment(): void;
decrement(): void;
};
function createCounterService(): CounterService {
const s = store({ count: 0 });
return {
state: readonly(s),
increment() {
s.update((st) => ({ count: st.count + 1 }));
},
decrement() {
s.update((st) => ({ count: st.count - 1 }));
},
};
}
const counter = createCounterService();
counter.state.value.count; // readable
// counter.state.value = ...; // TS compile error — read-onlySymbol.dispose / using Declarations
All Subscription, ComputedSignal, and Scope handles implement [Symbol.dispose], enabling the TC39 explicit resource management syntax:
{
using sub = effect(() => console.log(count.value));
using doubled = computed(() => count.value * 2);
// both are automatically disposed when the block exits
}Working with Other Vielzeug Libraries
With Sourceit
Use Stateit signals for local UI intent and Sourceit for remote/list data orchestration.
import { signal } from '@vielzeug/stateit';
import { createRemoteSource } from '@vielzeug/sourceit';
const search = signal('');
const source = createRemoteSource({
fetch: ({ page }) => api.items.list({ page, search: search.value }),
});
search.subscribe(() => {
source.page(1);
void source.refresh();
});Best Practices
1. Signals for Primitive Values, Stores for Objects
// ✅ signal for a single scalar
const isOpen = signal(false);
// ✅ store for structured objects
const user = store({ id: '', name: '', role: 'guest' });
// ❌ store for a simple boolean — overcomplicated
const isOpen = store({ value: false });2. Computed for Derived Values
// ✅ computed instead of duplicating logic in effects
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
// ❌ avoid manually syncing derived state in an effect
const fullNameState = signal('');
effect(() => {
fullNameState.value = `${firstName.value} ${lastName.value}`;
});3. Watch Slices with Getter Sources or computed()
Both approaches work; choose based on reuse needs:
// ✅ getter source — simple for one-off watches
watch(() => userStore.value.count, (count) => console.log('count:', count));
// ✅ composed with computed() — better for shared/complex selections
const countSignal = computed(() => userStore.value.count);
watch(countSignal, (count) => console.log('count:', count));
countSignal.dispose();4. Batch Multiple Updates
// ✅ one notification instead of two
batch(() => {
x.value = 1;
y.value = 2;
});5. Use .update() to Avoid Stale Reads
// ✅ concise and avoids re-reading .value
count.update((n) => n + 1);
// also fine for stores
cart.update((s) => ({ ...s, items: [...s.items, newItem] }));6. Dispose Effects and Computeds When No Longer Needed
const sub = effect(() => (document.title = `Count: ${count.value}`));
// when component unmounts:
sub.dispose();7. Use untrack to Break Unwanted Dependencies
effect(() => {
const id = userId.value; // tracked
const name = untrack(() => users.value[id]); // NOT tracked — avoids re-run on users change
render(id, name);
});Testing
Stateit stores are plain objects — no special test utilities needed. Create a fresh store in beforeEach and dispose any active effects in afterEach.
import { store, watch } from '@vielzeug/stateit';
import type { Store } from '@vielzeug/stateit';
describe('counter', () => {
let s: Store<{ count: number }>;
beforeEach(() => {
s = store({ count: 0 });
});
it('patches count', () => {
s.patch({ count: 1 });
expect(s.value.count).toBe(1);
});
it('notifies watcher on change', () => {
const listener = vi.fn();
const sub = watch(s, listener);
s.patch({ count: 5 });
// notifications are synchronous — no await needed
expect(listener).toHaveBeenCalledWith({ count: 5 }, { count: 0 });
sub.dispose();
});
});For isolated signal tests, create signals in the test scope — they are garbage-collected unless an active effect() holds a reference:
it('computed updates reactively', () => {
const n = signal(2);
const sq = computed(() => n.value ** 2);
expect(sq.value).toBe(4);
n.value = 3;
expect(sq.value).toBe(9);
sq.dispose();
});