StunkStunk
Integrations

Vanilla / TypeScript

Use Stunk without any framework — pure reactive state in plain JavaScript or TypeScript.

Stunk has zero dependencies and no framework requirements. Everything works in plain JavaScript or TypeScript — chunk, computed, select, batch, asyncChunk, and all middleware.


Basic state

import { chunk } from "stunk";

const count = chunk(0);

// Read
count.get(); // 0

// Write
count.set(1);
count.set((prev) => prev + 1);

// Reset
count.reset();

Subscribing to changes

subscribe is the core reactivity primitive. The callback fires immediately with the current value, then on every subsequent change. It returns an unsubscribe function.

const count = chunk(0);

const unsubscribe = count.subscribe((value) => {
  document.querySelector("#count").textContent = String(value);
});

count.set(1); // DOM updates
count.set(2); // DOM updates

// Stop listening
unsubscribe();

Deriving state

import { chunk } from "stunk";

const price = chunk(100);
const discounted = price.derive((p) => p * 0.9);

discounted.subscribe((value) => {
  document.querySelector("#price").textContent = `$${value}`;
});

price.set(200); // discounted updates to 180, DOM updates

Selecting from objects

import { chunk, select } from "stunk";

const user = chunk({ name: "Fola", age: 25, role: "admin" });

const userName = select(user, (u) => u.name);

userName.subscribe((name) => {
  document.querySelector("#name").textContent = name;
});

// Only triggers when name changes, not age or role
user.set((prev) => ({ ...prev, age: 26 })); // no DOM update
user.set((prev) => ({ ...prev, name: "Tunde" })); // DOM updates

Computed from multiple chunks

import { chunk, computed } from "stunk";

const price = chunk(100);
const quantity = chunk(3);
const total = computed([price, quantity], (p, q) => p * q);

total.subscribe((value) => {
  document.querySelector("#total").textContent = `$${value}`;
});

price.set(50); // total → 150
quantity.set(10); // total → 500

Batch updates

Group multiple updates to avoid redundant subscriber notifications:

import { chunk, batch } from "stunk";

const firstName = chunk("Fola");
const lastName = chunk("Ade");

// Without batch — two notifications
firstName.set("Tunde");
lastName.set("Bello");

// With batch — one notification after both updates
batch(() => {
  firstName.set("Tunde");
  lastName.set("Bello");
});

Async state

import { asyncChunk } from "stunk";

const postsChunk = asyncChunk(async () => {
  const res = await fetch("/api/posts");
  return res.json();
});

postsChunk.subscribe(({ loading, error, data }) => {
  if (loading) {
    document.querySelector("#posts").textContent = "Loading...";
    return;
  }
  if (error) {
    document.querySelector("#posts").textContent = `Error: ${error.message}`;
    return;
  }
  document.querySelector("#posts").innerHTML = data
    .map((p) => `<li>${p.title}</li>`)
    .join("");
});

// Refetch manually
document.querySelector("#reload").addEventListener("click", () => {
  postsChunk.reload();
});

Middleware

import { chunk } from "stunk";
import { logger, nonNegativeValidator } from "stunk/middleware";

const score = chunk(0, [logger, nonNegativeValidator]);

score.set(10); // logs and applies
score.set(-1); // throws, value unchanged

withHistory

import { chunk } from "stunk";
import { withHistory } from "stunk/middleware";

const text = chunk("");
const editor = withHistory(text);

editor.set("Hello");
editor.set("Hello World");

editor.undo(); // "Hello"
editor.redo(); // "Hello World"

withPersistence

import { chunk } from "stunk";
import { withPersistence } from "stunk/middleware";

const theme = chunk<"light" | "dark">("light");
const persistedTheme = withPersistence(theme, { key: "app-theme" });

persistedTheme.set("dark");
// saved to localStorage

// Next page load — automatically restored to "dark"

Cleanup

Always call destroy() when a chunk is no longer needed to clear subscribers and free memory:

const count = chunk(0);
const sub = count.subscribe(console.log);

// When done
count.destroy(); // clears all subscribers and removes from registry

In single-page apps, long-lived global chunks rarely need to be destroyed. destroy() is most useful for short-lived chunks created dynamically — like per-item state in a list.


What's next?

On this page