Stateit
Stateit is a tiny, zero-dependency reactive library that unifies two complementary primitives:
- Signals — fine-grained reactive values (
signal,computed,effect,watch) - Stores — structured reactive state containers for plain objects (
store)
Both share the same .value access and watch() / effect() subscription model. A Store<T> is a Signal<T>, so every signal primitive works on stores too.
Installation
sh
pnpm add @vielzeug/stateitsh
npm install @vielzeug/stateitsh
yarn add @vielzeug/stateitQuick Start
Signals
ts
import { signal, computed, effect, watch, batch } from '@vielzeug/stateit';
const count = signal(0);
const doubled = computed(() => count.value * 2);
// Side-effect: runs immediately and re-runs on change
const sub = effect(() => {
console.log('doubled:', doubled.value);
});
count.value = 5; // → logs "doubled: 10"
// Explicit subscription — only fires on change, not immediately
const stopWatch = watch(count, (next, prev) => {
console.log(prev, '→', next);
});
batch(() => {
count.value = 10;
count.value = 20; // only one notification
});
sub.dispose(); // dispose effect
stopWatch.dispose(); // unsubscribe watch
doubled.dispose(); // dispose computedStores
ts
import { store, watch, batch } from '@vielzeug/stateit';
const counter = store({ count: 0 });
// Read
console.log(counter.value.count); // 0
// Watch all changes
const stopWatch = watch(counter, (curr, prev) => {
console.log(`${prev.count} → ${curr.count}`);
});
// Watch a selected slice — compose with store.select()
const countSignal = counter.select((s) => s.count);
watch(countSignal, (count, prev) => {
console.log('count:', prev, '→', count);
});
// Partial patch
counter.patch({ count: 1 });
// Updater function
counter.update((s) => ({ ...s, count: s.count + 1 }));
// Batch: one notification for all writes
batch(() => {
counter.patch({ count: 10 });
counter.update((s) => ({ ...s, count: s.count + 1 }));
});
// Reset to original initial state
counter.reset();
// Clean up
stopWatch.dispose();
counter.freeze();Why Stateit?
Plain variables don't notify anything when they change. Framework-specific stores add boilerplate and coupling.
ts
// Before — manual notification
let count = 0;
const listeners: Array<() => void> = [];
function setCount(n: number) {
count = n;
listeners.forEach((fn) => fn());
}
// After — Stateit signals
import { signal, effect } from '@vielzeug/stateit';
const count = signal(0);
effect(() => console.log(count.value)); // auto-tracks dependencies
count.value = 1; // notifies automatically| Feature | Stateit | Zustand | Jotai | Nanostores |
|---|---|---|---|---|
| Bundle size | 2.0 KB | ~3.5 kB | ~7 kB | ~2 kB |
| Framework-agnostic | ✅ | ✅ | React | ✅ |
| Fine-grained reactivity | ✅ | ❌ | ✅ | ✅ |
| Structured stores | ✅ | ✅ | Manual | ❌ |
| Zero dependencies | ✅ | ❌ | ❌ | ✅ |
Use Stateit when you need fine-grained reactivity without framework lock-in, or when building web components and vanilla JS apps.
Consider alternatives when you need DevTools integration (Zustand), React Suspense support (Jotai), or server-side stores.
Features
Signals
signal(value, options?)— reactive atom; read.value, write.value = next,.update(fn), peek untracked with.peek()computed(fn, options?)— lazy derived signal; recomputes when deps change; call.dispose()to stop trackingeffect(fn, options?)— side-effect that re-runs when any signal read inside it changes; returns aSubscriptionwatch(source, cb, options?)— explicit subscription that fires only when the value changes; returns aSubscriptionderived(sources, fn)— multi-source derived signal combining multiple signals into onenextValue(source, predicate?)— async helper that resolves with the next matching emissionuntrack(fn)— read signals inside an effect without creating subscriptionsreadonly(sig)— narrows a signal to aReadonlySignal<T>view (identity, no proxy)toValue(v)— unwrap a plain value or signal transparentlywritable(get, set, options?)— bidirectional computed for form adapters and transformationsbatch(fn)— flush all notifications once after bulk updatesonCleanup(fn)— register teardown from inside an effect without using the return valueisSignal(v)/isStore(v)— type guards
Stores
store(init, options?)— structured reactive object container extendingSignal<T>.patch(partial)— shallow-merge aPartial<T>into state.update(fn)— derive next state from current via an updater function.select(selector, options?)— lazily derivedComputedSignal<U>from a state slice; compose withwatch()to watch slices.reset()— restore the initial state baseline.freeze()— freeze the store; further writes are silently ignored- Zero dependencies — no supply chain risk; < 2 kB gzipped
Ergonomics
Subscription— all dispose handles support.dispose(), direct call(), and[Symbol.dispose](usingdeclarations)EffectOptions— per-effectmaxIterationsandonErrorcallbacksconfigureStateit()— global defaults (e.g.maxEffectIterations)shallowEqual— exported equality helper (the default for stores)
Compatibility
| Environment | Support |
|---|---|
| Browser | ✅ |
| Node.js | ✅ |
| SSR | ✅ |
| Deno | ✅ |