Why Ripple?
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 — Ripple signals
import { signal, effect } from '@vielzeug/ripple';
const count = signal(0);
effect(() => console.log(count.value)); // auto-tracks dependencies
count.value = 1; // notifies automatically| Feature | Ripple | Zustand | Jotai | Nanostores |
|---|---|---|---|---|
| Bundle size | 5.4 KB | ~3.5 kB | ~7 kB | ~2 kB |
| Zero dependencies | ||||
| Framework-agnostic | React-first | |||
| Fine-grained reactivity | ||||
| Structured object stores | store, lens) | Manual atoms | ||
| Async computed | asyncComputed) | Manual | ||
| Undo / redo history | storeWithHistory) | Manual | ||
| Computed signals | Selectors | |||
| Batched writes | batch) | |||
| Explicit cleanup / scopes | scope, onCleanup) | |||
| SSR support | ||||
| TypeScript — strict generics | Partial | |||
| React Suspense | ||||
| Redux DevTools |
Use Ripple when you need fine-grained, per-property reactivity without framework lock-in — especially for web components, vanilla JS apps, or any environment where you want zero runtime dependencies and explicit lifecycle control.
Consider alternatives when you are React-only and need Suspense or React Query integration (Jotai), need Redux DevTools out of the box (Zustand), or need a minimal atom store with no extra features (Nanostores).
Installation
sh
pnpm add @vielzeug/ripplesh
npm install @vielzeug/ripplesh
yarn add @vielzeug/rippleQuick Start
ts
import { signal, derive, effect, watch, batch } from '@vielzeug/ripple';
const count = signal(0);
const doubled = derive(count, (n) => n * 2); // ComputedSignal<number>
// 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; // one notification fires with the final value
});
sub.dispose();
stopWatch.dispose();
doubled.dispose();ts
import { store, watch, batch, computed } from '@vielzeug/ripple';
const counter = store({ count: 0, label: 'counter' });
// Read
console.log(counter.value.count); // 0
// Watch a typed lens — only fires when that path changes
const countLens = counter.lens('count'); // Signal<number>
const stopWatch = watch(countLens, (next, prev) => {
console.log('count:', prev, '→', next);
});
// Derived slice
const label = computed(() => counter.value.label); // ComputedSignal<string>
// Mutations
counter.patch({ count: 1 }); // shallow merge
counter.replace((s) => ({ ...s, count: s.count + 1 })); // replace via fn
countLens.value = 10; // write directly through the lens
batch(() => {
counter.patch({ count: 5 });
counter.patch({ label: 'done' });
});
counter.reset();
stopWatch.dispose();
label.dispose();Features
signal(value, options?)— reactive atom; read.value, write.value = next;batched: truecoalesces rapid writes into one microtask notificationcomputed(fn, options?)— lazy derived signal; glitch-free; auto-tracks dependencies read insidefneffect(fn, options?)— side-effect that re-runs when dependencies change; options:scheduler('sync'|'microtask'| custom fn),maxIterations,nameeffectAsync(fn, options?)— async side-effect with anAbortSignalthat fires on re-run or dispose; returnsAsyncSubscriptionwith[Symbol.asyncDispose]resource(factory, options?)— preferred name for async computed; exposes.data,.error,.isLoadingsignals; factory receives anAbortSignalasyncComputed(factory, options?)— same asresource(); kept for compatibilitywatch(source, cb, options?)— explicit subscription that fires only when the value changes; returns aSubscriptionbatch(fn)— flush all notifications once after bulk updatesuntrack(fn)— read signals inside an effect without creating subscriptionsonCleanup(fn)— register teardown from inside an effect orscopewithout using the return valuescope(setup?)— isolated cleanup context; collect teardown viaonCleanupinsidescope.run(fn); release withscope.dispose()derive(source, project, options?)— project a reactive source into aComputedSignal; cleaner alternative toselector(source, project)filter(source, predicate, options?)— filter a reactive source; returns value when predicate istrue,undefinedotherwise; type-predicate overload narrowsT → U | undefinedselector(source, project, predicate?, options?)— project + optional filter utility; usederive()/filter()for single-concern casesreadonly(source)— wraps any signal as aComputedSignal— read-only at the type level;dispose()is always a no-opdebugEffect(fn, options?)— likeeffect(), but logs reactive deps on every run; import from@vielzeug/ripple/devtools— tree-shaken from production bundlesisSignal(v),isComputed(v),isStore(v)— type guards using an internal symbol markerstore(init, options?)— structured reactive object container.patch(partial)— shallow-merge aPartial<T>into state.replace(fn)— derive next state from current via a function; same-reference return is a no-op.reset()— restore the initial state baseline.lens<P>(path)— cached writableSignalfor a property or dot-path; writes produce an immutable copystoreWithHistory(storeOrInit, options?)— store with snapshot history; accepts an existingStore<T>(not owned) or a plain object;undo(),redo(),historyAt(i),historyLength; reactivecanUndo/canRedopropertiesgetDevToolsHook()— returns the currently installed DevTools hook, ornull; install via@vielzeug/ripple/devtools- Glitch-free propagation — computed signals propagate in dependency order; effects always observe a consistent snapshot
- Infinite loop detection — built-in guard against effect re-entry cycles (100 iterations default, configurable per effect)
- Automatic computed disposal —
computed()created insideeffect()auto-disposes with the effect
Sub-paths
| Import | Purpose |
|---|---|
@vielzeug/ripple | All exports and types |
@vielzeug/ripple/devtools | installDevTools, debugEffect — DevTools hook and reactive source tracing (dev-only, tree-shaken) |
@vielzeug/ripple/ssr | SSR tracking isolation helpers (withProvider, runWithProvider, createAsyncProvider). Node.js only. |