Skip to content
VersionSizeTypeScriptDependencies
Stateit logo

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/stateit
sh
npm install @vielzeug/stateit
sh
yarn add @vielzeug/stateit

Quick 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 computed

Stores

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
FeatureStateitZustandJotaiNanostores
Bundle size2.0 KB~3.5 kB~7 kB~2 kB
Framework-agnosticReact
Fine-grained reactivity
Structured storesManual
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 tracking
  • effect(fn, options?) — side-effect that re-runs when any signal read inside it changes; returns a Subscription
  • watch(source, cb, options?) — explicit subscription that fires only when the value changes; returns a Subscription
  • derived(sources, fn) — multi-source derived signal combining multiple signals into one
  • nextValue(source, predicate?) — async helper that resolves with the next matching emission
  • untrack(fn) — read signals inside an effect without creating subscriptions
  • readonly(sig) — narrows a signal to a ReadonlySignal<T> view (identity, no proxy)
  • toValue(v) — unwrap a plain value or signal transparently
  • writable(get, set, options?) — bidirectional computed for form adapters and transformations
  • batch(fn) — flush all notifications once after bulk updates
  • onCleanup(fn) — register teardown from inside an effect without using the return value
  • isSignal(v) / isStore(v) — type guards

Stores

  • store(init, options?) — structured reactive object container extending Signal<T>
  • .patch(partial) — shallow-merge a Partial<T> into state
  • .update(fn) — derive next state from current via an updater function
  • .select(selector, options?) — lazily derived ComputedSignal<U> from a state slice; compose with watch() 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] (using declarations)
  • EffectOptions — per-effect maxIterations and onError callbacks
  • configureStateit() — global defaults (e.g. maxEffectIterations)
  • shallowEqual — exported equality helper (the default for stores)

Compatibility

EnvironmentSupport
Browser
Node.js
SSR
Deno

See Also