Skip to content

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

bash
pnpm add @stateloom/core
bash
npm install @stateloom/core
bash
yarn add @stateloom/core

For paradigm adapters, add the ones you need:

bash
pnpm add @stateloom/store
bash
pnpm add @stateloom/atom
bash
pnpm add @stateloom/proxy

Core Signals

Signals are the fundamental reactive primitive. They hold a value and notify dependents when it changes.

typescript
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:

typescript
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 20

Custom Equality

By default, signals use Object.is for equality. Pass a custom function for objects:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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 sharing

DOM Integration

StateLoom works with any DOM manipulation approach. Here is a counter using signals and effects to reactively update the DOM:

typescript
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:

typescript
// 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

Live Demo

Try the Vanilla JS example directly in your browser:

Open in StackBlitz | Open Static Demo