Stunk
March 22, 2026 ยท View on GitHub
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