Vanilla JS
Use StateLoom without any framework. This guide covers core signals, stores, atoms, and proxies in plain JavaScript or TypeScript.
Data Flow
Prerequisites
- Node.js 18+ or any modern browser
- TypeScript 5.0+ (optional but recommended)
Installation
pnpm add @stateloom/corenpm install @stateloom/coreyarn add @stateloom/coreFor paradigm adapters, add the ones you need:
pnpm add @stateloom/storepnpm add @stateloom/atompnpm add @stateloom/proxyCore Signals
Signals are the fundamental reactive primitive. They hold a value and notify dependents when it changes.
import { signal, computed, effect, batch } from '@stateloom/core';
// Create a signal
const count = signal(0);
count.get(); // 0
count.set(5); // notifies dependents
// Derive values with computed
const doubled = computed(() => count.get() * 2);
doubled.get(); // 10
// React to changes with effect
const dispose = effect(() => {
console.log(`count=${count.get()}, doubled=${doubled.get()}`);
return undefined;
});
// logs: "count=5, doubled=10"
count.set(10);
// logs: "count=10, doubled=20"
// Stop the effect
dispose();Batching Multiple Updates
Without batch(), each set() triggers effects immediately. Use batch() to coalesce multiple updates:
const a = signal(0);
const b = signal(0);
effect(() => {
console.log(a.get(), b.get());
return undefined;
});
// logs: 0 0
// Without batch: effect runs twice
a.set(1); // logs: 1 0
b.set(2); // logs: 1 2
// With batch: effect runs once
batch(() => {
a.set(10);
b.set(20);
});
// logs: 10 20Custom Equality
By default, signals use Object.is for equality. Pass a custom function for objects:
const user = signal(
{ name: 'Alice', age: 30 },
{ equals: (a, b) => a.name === b.name && a.age === b.age },
);
user.set({ name: 'Alice', age: 30 }); // no notification (values equal)Subscribing to Changes
Use .subscribe() for callback-based listening:
const count = signal(0);
const unsub = count.subscribe((value) => {
console.log('Count changed:', value);
});
count.set(5); // logs: "Count changed: 5"
unsub();Store Paradigm
Stores combine state and actions in a single object. They support middleware for cross-cutting concerns:
import { createStore } from '@stateloom/store';
const todoStore = createStore((set, get) => ({
todos: [] as { id: string; text: string; done: boolean }[],
filter: 'all' as 'all' | 'active' | 'done',
addTodo: (text: string) =>
set((state) => ({
todos: [...state.todos, { id: crypto.randomUUID(), text, done: false }],
})),
toggleTodo: (id: string) =>
set((state) => ({
todos: state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
})),
getActiveCount: () => get().todos.filter((t) => !t.done).length,
}));
// Use the store
todoStore.getState().addTodo('Learn StateLoom');
todoStore.getState().addTodo('Build something');
console.log(todoStore.getState().getActiveCount()); // 2
// Subscribe to changes
const unsub = todoStore.subscribe((state, prevState) => {
console.log('Todos changed:', state.todos.length);
});Store Middleware
Add persistence, devtools, or logging:
import { createStore } from '@stateloom/store';
import { devtools, logger } from '@stateloom/devtools';
import { persist } from '@stateloom/persist';
const store = createStore(
(set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}),
{
middleware: [
logger({ diff: true }),
devtools({ name: 'Counter' }),
persist({ key: 'counter' }),
],
},
);Atom Paradigm
Atoms are small, composable pieces of state:
import { atom, derived, writableAtom } from '@stateloom/atom';
const countAtom = atom(0);
const nameAtom = atom('Alice');
// Derived atoms track dependencies automatically
const greetingAtom = derived((get) => {
return `Hello, ${get(nameAtom)}! Count: ${get(countAtom)}`;
});
// Writable atoms with custom write logic
const incrementAtom = writableAtom(null, (get, set) => {
set(countAtom, get(countAtom) + 1);
});Proxy Paradigm
Write state with regular JavaScript mutations:
import { observable, snapshot, observe } from '@stateloom/proxy';
const state = observable({
user: { name: 'Alice', age: 30 },
todos: [{ text: 'Learn StateLoom', done: false }],
});
// Observe changes (auto-tracks accessed properties)
const dispose = observe(() => {
console.log(`${state.user.name} has ${state.todos.length} todos`);
});
// Mutate directly
state.user.name = 'Bob';
// logs: "Bob has 1 todos"
state.todos.push({ text: 'Build app', done: false });
// logs: "Bob has 2 todos"
// Immutable snapshot for comparison or serialization
const snap = snapshot(state);
// snap is deeply frozen with structural sharingDOM Integration
StateLoom works with any DOM manipulation approach. Here is a counter using signals and effects to reactively update the DOM:
import { signal, computed, effect, batch } from '@stateloom/core';
const count = signal(0);
const doubled = computed(() => count.get() * 2);
const quadrupled = computed(() => doubled.get() * 2);
function renderCounter(root: HTMLElement): void {
root.innerHTML = `
<section>
<h2>Counter</h2>
<p>Count: <span id="count-val"></span></p>
<p>Doubled: <span id="doubled-val"></span></p>
<p>Quadrupled: <span id="quad-val"></span></p>
<button id="dec-btn">-1</button>
<button id="inc-btn">+1</button>
<button id="inc5-btn">+5 (batched)</button>
<button id="reset-btn">Reset</button>
</section>
`;
const countEl = root.querySelector<HTMLSpanElement>('#count-val')!;
const doubledEl = root.querySelector<HTMLSpanElement>('#doubled-val')!;
const quadEl = root.querySelector<HTMLSpanElement>('#quad-val')!;
// Reactively update DOM when signals change
effect(() => {
countEl.textContent = String(count.get());
return undefined;
});
effect(() => {
doubledEl.textContent = String(doubled.get());
return undefined;
});
effect(() => {
quadEl.textContent = String(quadrupled.get());
return undefined;
});
root.querySelector('#inc-btn')!.addEventListener('click', () => {
count.set(count.get() + 1);
});
root.querySelector('#dec-btn')!.addEventListener('click', () => {
count.set(count.get() - 1);
});
root.querySelector('#reset-btn')!.addEventListener('click', () => {
count.set(0);
});
root.querySelector('#inc5-btn')!.addEventListener('click', () => {
batch(() => {
for (let i = 0; i < 5; i++) count.set(count.get() + 1);
});
});
}Web Worker Usage
Core signals work in Web Workers with no modifications:
// worker.ts
import { signal, computed, effect } from '@stateloom/core';
const data = signal<number[]>([]);
const total = computed(() => data.get().reduce((sum, n) => sum + n, 0));
effect(() => {
self.postMessage({ total: total.get() });
return undefined;
});
self.onmessage = (event) => {
data.set(event.data);
};Tips
Batch Related Updates
Always use batch() when updating multiple signals that effects read together. Without it, effects see intermediate states.
Avoid Side Effects in Computed
Computed functions should be pure. Use effect() for side effects.
Example App
See the complete Vanilla JS example for a working app that demonstrates signals, stores, atoms, and proxies with manual DOM updates.
Next Steps
- Core API Reference -- Full signal/computed/effect documentation
- Store API Reference -- Store patterns and middleware
- React + Vite Guide -- Add React integration
- Next.js Guide -- Server-side rendering
Live Demo
Try the Vanilla JS example directly in your browser: