stunk Svelte Themes

Stunk

A framework-agnostic state management library that keeps your app’s state clean and simple. It uses a fine-grained state model, breaking state into independent, manageable chunks.

Stunk

Stunk is a lightweight, framework-agnostic state management library built on atomic state principles. Break state into independent chunks — each one reactive, composable, and self-contained.

  • Pronunciation: Stunk (a playful blend of "state" and "chunk")

Features

  • 🚀 Lightweight — 3.32kB gzipped, zero dependencies
  • ⚛️ Atomic — break state into independent chunks
  • 🔄 Reactive — fine-grained updates, only affected components re-render
  • 🧮 Auto-tracking computed — no dependency arrays, just call .get()
  • 🌐 Async & Query layer — loading, error, caching, deduplication, pagination built in
  • 🔁 Mutations — reactive POST/PUT/DELETE with automatic cache invalidation
  • 📦 Batch updates — group multiple updates into one render
  • 🔌 Middleware — logging, persistence, validation — plug anything in
  • ⏱️ Time travel — undo/redo state changes
  • 🔍 TypeScript first — full type inference, no annotations needed

Installation

npm install stunk
# or
yarn add stunk
# or
pnpm add stunk

📖 Read the docs


Core State

import { chunk } from "stunk";

const count = chunk(0);

count.get();                    // 0
count.set(10);                  // set directly
count.set((prev) => prev + 1);  // updater function
count.peek();                   // read without tracking dependencies
count.reset();                  // back to 0
count.destroy();                // clear all subscribers

Computed — auto dependency tracking

No dependency arrays. Any chunk whose .get() is called inside the function is tracked automatically:

import { chunk, computed } from "stunk";

const price    = chunk(100);
const quantity = chunk(3);

const total = computed(() => price.get() * quantity.get());

total.get(); // 300

price.set(200);
total.get(); // 600 — recomputed automatically

Use .peek() to read without tracking:

const taxRate = chunk(0.1);
const subtotal = computed(() => price.get() * (1 + taxRate.peek()));
// only recomputes when price changes

Async & Query

import { asyncChunk } from "stunk/query";

const userChunk = asyncChunk(
  async ({ id }: { id: number }) => fetchUser(id),
  {
    key: "user",              // deduplicates concurrent requests
    keepPreviousData: true,   // no UI flicker on param changes
    staleTime: 30_000,
    onError: (err) => toast.error(err.message),
  }
);

userChunk.setParams({ id: 1 });
// { loading: true, data: null, error: null }
// { loading: false, data: { id: 1, name: "..." }, error: null }

Mutations

Reactive POST/PUT/DELETE — one function, always safe to await or fire and forget:

import { mutation } from "stunk/query";

const createPost = mutation(
  async (data: NewPost) => fetchAPI("/posts", { method: "POST", body: data }),
  {
    invalidates: [postsChunk],                  // auto-reloads on success
    onSuccess: () => toast.success("Created!"),
    onError:   (err) => toast.error(err.message),
  }
);

// Fire and forget — safe
createPost.mutate({ title: "Hello" });

// Await for local control — no try/catch needed
const { data, error } = await createPost.mutate({ title: "Hello" });
if (!error) router.push("/posts");

Global Query Config

Set defaults once for all async chunks and mutations:

import { configureQuery } from "stunk/query";

configureQuery({
  query: {
    staleTime: 30_000,
    retryCount: 3,
    onError: (err) => toast.error(err.message),
  },
  mutation: {
    onError: (err) => toast.error(err.message),
  },
});

Middleware

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

const score = chunk(0, {
  middleware: [logger(), nonNegativeValidator]
});

score.set(10); // logs: "Setting value: 10"
score.set(-1); // throws: "Value must be non-negative!"

History (undo/redo)

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

const count   = chunk(0);
const tracked = history(count);

tracked.set(1);
tracked.set(2);
tracked.undo();   // 1
tracked.redo();   // 2
tracked.reset();  // 0 — clears history too

Persist

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

const theme = chunk<"light" | "dark">("light");
const saved = persist(theme, { key: "theme" });

saved.set("dark");       // saved to localStorage
saved.clearStorage();    // remove from localStorage

React

import { chunk, computed } from "stunk";
import { useChunk, useChunkValue, useAsyncChunk, useMutation } from "stunk/react";

const counter = chunk(0);
const double  = computed(() => counter.get() * 2);

function Counter() {
  const [count, setCount] = useChunk(counter);
  const doubled           = useChunkValue(double);

  return (
    <div>
      <p>Count: {count} — Doubled: {doubled}</p>
      <button onClick={() => setCount((n) => n + 1)}>+</button>
    </div>
  );
}

// Async
function PostList() {
  const { data, loading, error } = useAsyncChunk(postsChunk);
  if (loading) return <p>Loading...</p>;
  return <ul>{data?.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

// Mutation
function CreatePostForm() {
  const { mutate, loading, error } = useMutation(createPost);

  const handleSubmit = async (data: NewPost) => {
    const { error } = await mutate(data);
    if (!error) router.push("/posts");
  };
}

Package exports

Import Contents
stunk chunk, computed, select, batch, isChunk, and more
stunk/react useChunk, useChunkValue, useAsyncChunk, useInfiniteAsyncChunk, useMutation
stunk/query asyncChunk, infiniteAsyncChunk, combineAsyncChunks, mutation, configureQuery
stunk/middleware history, persist, logger, nonNegativeValidator

Contributing

Contributions are welcome — open a pull request or issue.

License

MIT

Top categories

Loading Svelte Themes