@kdeveloper/kvark
v1.6.0
Published
Atomic state management with explicit dependency graphs
Readme
@kdeveloper/kvark
Atomic state management for React, Preact, and Vue 3 with explicit dependency graphs, stale-while-revalidate, and first-class external invalidation.
Inspired by Jotai, but built around a key difference: dependencies are declared upfront rather than inferred at runtime. This makes the data graph statically analysable, enables parallel loading, and allows invalidation from anywhere — WebSocket handlers, SSE streams, timers, Service Workers — without framework hooks.
Why Kvark?
| | Jotai | Kvark |
| ---------------------- | -------------------------------- | ----------------------------------------------- |
| Dependency declaration | Implicit (via get(atom) calls) | Explicit, via dependencies field |
| atom() signature | atom(read, write?) | atom({ read, write?, dependencies? }) |
| Async model | Optional | read, write, ctx.read — always async |
| Parallel loading | Manual Promise.all | Built-in through dependencies |
| Stale-while-revalidate | Re-suspends on revalidation | Shows stale data; Suspense only on first load |
| atomFamily | External (jotai/utils) | Core, with LRU and invalidation |
| External invalidation | Manual via store mutation APIs | Explicit store.invalidate() / StoreClient |
| TypeScript strictness | strict: true recommended | strict: true + 8 extra flags, any forbidden |
Installation
pnpm add @kdeveloper/kvark
# optional peers: react >=18, preact >=10, and/or vue >=3.4Quick Start
import { atom, createStore } from "@kdeveloper/kvark";
import { Provider, useApplyAtom, useAtomValue } from "@kdeveloper/kvark/react";
// 1. Create atoms
const userIdAtom = atom({
debugLabel: "userId",
read: async () => 1,
write: async (_ctx, _id: number) => {},
});
const userAtom = atom({
debugLabel: "user",
dependencies: { userId: userIdAtom },
read: async (ctx) => {
const id = await ctx.read("userId");
const res = await fetch(`/api/users/${id}`, { signal: ctx.signal });
return res.json();
},
});
// 2. Create a store
const store = createStore();
// 3. Wrap your app
function App() {
return (
<Provider store={store}>
<Suspense fallback="Loading...">
<UserCard />
</Suspense>
</Provider>
);
}
// 4. Use in components
function UserCard() {
const user = useAtomValue(userAtom);
return <div>{user.name}</div>;
}Preact
The same atoms and store work with @kdeveloper/kvark/preact — the API is identical to the React integration. Replace the React import with the Preact one:
import { Provider, useApplyAtom, useAtomValue } from "@kdeveloper/kvark/preact";Hooks use only preact and preact/hooks internally — no preact/compat aliases required.
Vue 3
The same atoms and store work with @kdeveloper/kvark/vue. Composables mirror the React hooks; useAtomValue returns an awaitable shallowRef — use .value in <script setup> (templates unwrap refs automatically). Until the first read resolves, that ref’s value is undefined; TypeScript types it as V | undefined (ThenableShallowRef<V>). After await useAtomValue(atom) you get Readonly<ShallowRef<V>> with a defined .value. The ref also implements PromiseLike, so awaiting it in an async setup suspends until the first read resolves — see Suspense (Vue 3).
Composables must run inside a Provider subtree. Put useAtomValue / useAtom in a child component (or a nested route), not in the same component that renders Provider without passing the store through a wrapper.
atoms.ts — lift the atom definitions from the Quick Start into a shared module:
import { atom } from "@kdeveloper/kvark";
export const userIdAtom = atom({
debugLabel: "userId",
read: async () => 1,
write: async () => {},
});
export const userAtom = atom({
debugLabel: "user",
dependencies: { userId: userIdAtom },
read: async (ctx) => {
const id = await ctx.read("userId");
const res = await fetch(`/api/users/${id}`, { signal: ctx.signal });
return res.json();
},
});UserCard.vue
<script setup lang="ts">
import { useAtomValue } from "@kdeveloper/kvark/vue";
import { userAtom } from "./atoms";
const user = useAtomValue(userAtom);
</script>
<template>
<div>{{ user?.name }}</div>
</template>While the atom is pending, the unwrapped ref is undefined, so {{ user.name }} would throw at runtime and TypeScript flags user.name in script. Use optional chaining (as above), v-if="user", or await useAtomValue(userAtom) with <Suspense>.
With useAtomValue(atom, { observe: true }), the ref always holds an ObservedValue object (value, isStale, error); value is V | undefined while pending (ThenableObservedShallowRef<V>).
App.vue
<script setup lang="ts">
import { createStore } from "@kdeveloper/kvark";
import { Provider } from "@kdeveloper/kvark/vue";
import UserCard from "./UserCard.vue";
const store = createStore();
</script>
<template>
<Provider :store="store">
<UserCard />
</Provider>
</template>Unlike React, Vue’s <Suspense> only activates for async child components. Since useAtomValue and useAtom return PromiseLike refs, just await them in an async setup (or top-level <script setup>) to trigger Suspense — see Suspense (Vue 3).
Store Context
Kvark can inject arbitrary application context into every atom and mutation callback through the store, similar to router context in TanStack Router. The context is immutable for the lifetime of a store and is available in read, write, onMount, mutation, batch.fetch, and infinityAtom.queryFn.
First, declare the context type once with module augmentation:
import type { User, ApiClient } from "./api";
declare module "@kdeveloper/kvark" {
interface Register {
context: {
apiClient: ApiClient;
currentUser: User;
};
}
}Then create a store with that context:
import { createStore } from "@kdeveloper/kvark";
export const store = createStore({
context: {
apiClient,
currentUser,
},
});Now every Kvark callback can read ctx.global:
import { atom, mutation } from "@kdeveloper/kvark";
const userAtom = atom({
dependencies: { userId: userIdAtom },
read: async (ctx) => {
return ctx.global.apiClient.users.get(await ctx.read("userId"));
},
write: async (ctx, patch: Partial<User>) => {
await ctx.global.apiClient.users.update(ctx.global.currentUser.id, patch);
},
});
const clockAtom = atom(Date.now(), {
onMount: (set, { global }) => {
const id = setInterval(() => set(global.apiClient.now()), 1000);
return () => clearInterval(id);
},
});
const saveUserMutation = mutation(async (ctx, patch: Partial<User>) => {
await ctx.global.apiClient.users.update(ctx.global.currentUser.id, patch);
});infinityAtom also receives the same context via queryFn:
const feedAtom = infinityAtom({
initialCursor: 0,
queryFn: async ({ cursor, signal, global }) => {
return global.apiClient.feed.list({ cursor, signal });
},
getNextCursor: (lastPage) => lastPage.nextCursor,
});Batch families receive it via input.ctx.global inside batch.fetch.
Core Concepts
Atoms
An atom is the smallest unit of state. Its read function is always async, and dependencies must be declared explicitly.
import { atom } from "@kdeveloper/kvark";
// Primitive atom — no dependencies
const countAtom = atom({
debugLabel: "count",
read: async () => 0,
write: async (_ctx, _value: number) => {},
});
// Derived atom — reads from another atom
const doubleAtom = atom({
debugLabel: "double",
dependencies: { count: countAtom },
read: async (ctx) => {
const n = await ctx.read("count");
return n * 2;
},
});Streaming reads
For APIs that deliver line-by-line or chunked responses, use stream instead of read.
The stream must return an AsyncIterable; Kvark does not parse Response.body itself, so
your app stays in control of decoding and splitting bytes into domain chunks.
import { atom } from "@kdeveloper/kvark";
async function* lines(response: Response): AsyncGenerator<string> {
const reader = response.body?.pipeThrough(new TextDecoderStream()).getReader();
if (reader == null) {
return;
}
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += value;
const parts = buffer.split("\n");
buffer = parts.pop() ?? "";
yield* parts;
}
if (buffer !== "") {
yield buffer;
}
}
const logAtom = atom({
initialValue: [] as string[],
stream: async (ctx) => {
const response = await fetch("/api/logs", { signal: ctx.signal });
return lines(response);
},
reduce: (prev, line) => [...prev, line],
});Each yielded chunk is folded into a new full atom value with reduce, and subscribers
see those intermediate values immediately. store.read(logAtom) still resolves only
after the stream closes, with the final accumulated value.
Simple primitive atom (atom(initialValue))
For purely client-side state — counters, toggles, draft fields — there is a shorthand: pass an initial value directly. The result is a WritableAtom whose apply function accepts either the next value or an updater function (prev) => next. Both store.apply(atom, value) and store.write(atom, value) are supported: apply runs the atom's write callback, while write pushes straight into the cache. React/Preact/Vue hooks (useAtomValue, useApplyAtom, useAtom) work as usual.
import { atom, createStore } from "@kdeveloper/kvark";
const counterAtom = atom(0);
const themeAtom = atom<"light" | "dark">("light", { debugLabel: "theme" });
// External subscription via `onMount` (e.g. WebSocket, timer, broadcast channel)
const clockAtom = atom(Date.now(), {
debugLabel: "clock",
onMount: (set) => {
const id = setInterval(() => set(Date.now()), 1000);
return () => clearInterval(id);
},
});
const store = createStore();
await store.apply(counterAtom, 5);
await store.apply(counterAtom, (prev) => prev + 1);
store.write(counterAtom, 42);The shorthand is intentionally limited to debugLabel and onMount; for dependencies, stalePolicy, retry, or async loading use the full atom({ read, write?, ... }) form.
Note: if your initial value is itself an object that exposes a
readmethod (and would be parsed as an atom config), use the explicit form:atom({ read: async () => obj, write: async (ctx, next) => ctx.setOptimisticValue(next) }).
Writable atoms
An atom is writable when its config includes a write function. The return type becomes WritableAtom<Value, Args>, where Args is the tuple of arguments accepted by store.apply(atom, ...args) / useApplyAtom(atom)(...args).
const profileAtom = atom({
debugLabel: "profile",
read: async () => {
const res = await fetch("/api/profile");
return res.json() as Promise<Profile>;
},
write: async (ctx, patch: Partial<Profile>) => {
await fetch("/api/profile", {
method: "PATCH",
body: JSON.stringify(patch),
signal: ctx.signal,
});
},
});Lifecycle after apply: the store calls config.write(ctx, ...args), waits for the returned promise, then calls invalidate on the atom. This marks it stale and triggers a background refetch via read — the same stale-while-revalidate flow described above.
The ctx passed to write provides:
ctx.read(key)— read any declared dependency (same as inread).ctx.signal— anAbortSignaltied to the atom's lifecycle, useful for cancelling in-flight requests.ctx.setOptimisticValue(value)— synchronously update the atom's cached value before the async work completes (see below). Also accepts a mutator(prev) => next.ctx.writeOptimistic(atom, value)— synchronously write into another atom's cache with automatic rollback on error (see Dependent mutations). Also accepts a mutator.ctx.invalidate(atom)/ctx.invalidateMany(atoms)— mark unrelated atoms as stale so they refetch (see Dependent mutations).
Optimistic updates
Call ctx.setOptimisticValue(value) inside write to immediately reflect the new value in the UI while the mutation runs in the background. If the mutation throws (or the signal aborts), the store automatically rolls back to the state captured before the first setOptimisticValue call. Derived atoms that depend on this atom are marked stale so they re-render too.
const todoAtom = atom({
debugLabel: "todo",
read: async () => {
const res = await fetch("/api/todo");
return res.json() as Promise<Todo>;
},
write: async (ctx, title: string) => {
ctx.setOptimisticValue({ title, done: false });
await fetch("/api/todo", {
method: "PUT",
body: JSON.stringify({ title }),
signal: ctx.signal,
});
},
});setOptimisticValue also accepts a mutator function — the callback receives the previous value (or undefined if pending) and returns the next one:
write: async (ctx) => {
ctx.setOptimisticValue((prev) => ({ ...prev, loading: true }));
await fetch("/api/update", { signal: ctx.signal });
},If the PUT fails, the atom reverts to whatever value read had loaded before the optimistic update — no manual rollback needed.
Dependent mutations
The ctx inside write also provides methods for cross-atom side-effects:
ctx.invalidate(atom)— mark another atom asstaleand schedule a refetch. Does not participate in rollback; best called after the async work succeeds.ctx.invalidateMany(atoms)— same asinvalidate, but for multiple atoms at once.ctx.writeOptimistic(atom, value | mutator)— synchronously write a value into another atom's cache (same semantics asstore.write). If the mutation throws, the store rolls back all atoms touched bywriteOptimistictogether with the primary atom'ssetOptimisticValue.
A common pattern is a list atom alongside per-item atoms managed by atomFamily. Mutating an item should optimistically patch the list and then invalidate it after the request succeeds:
const todosListAtom = atom({
debugLabel: "todosList",
read: async () => {
const res = await fetch("/api/todos");
return res.json() as Promise<Todo[]>;
},
});
const todoAtom = atomFamily({
debugLabel: "todo",
read: (id: number) => async () => {
const res = await fetch(`/api/todos/${id}`);
return res.json() as Promise<Todo>;
},
write: (id: number) => async (ctx, patch: Partial<Todo>) => {
ctx.setOptimisticValue((prev) => ({ ...prev!, ...patch }));
ctx.writeOptimistic(todosListAtom, (list) =>
list?.map((t) => (t.id === id ? { ...t, ...patch } : t)),
);
await fetch(`/api/todos/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
signal: ctx.signal,
});
ctx.invalidate(todosListAtom);
},
});If the PATCH fails, both the item and the list revert to their pre-optimistic state. After success, invalidate(todosListAtom) triggers a background refetch to reconcile with the server.
Standalone mutations (mutation)
Sometimes a server action updates several atoms at once, and there is no single writable atom that should own the mutation. Use mutation(fn) to define that work once, then run it with store.mutate(m, ...args) or useMutation(m) in components.
The callback receives a MutationContext (the same optimistic and invalidation helpers as in write, but without setOptimisticValue or ctx.signal). Standalone mutations can read any atom with ctx.read(atom). In contrast, inside write, ctx.read(...) still means "read a declared dependency by key":
ctx.read(atom)— read any atom with the same semantics asstore.read(atom)/client.read(atom).ctx.writeOptimistic(atom, value | mutator)— same semantics as insidewrite. On throw, every atom touched bywriteOptimisticrolls back to its state before the first optimistic write in this run.ctx.invalidate/ctx.invalidateMany— mark atoms stale (not rolled back on error).
Unlike write, a successful mutate does not invalidate any atom automatically. Call ctx.invalidate / ctx.invalidateMany when you want stale-while-revalidate after the request completes.
A mutation can also return a value (for example, the id of a newly created entity). The return type is inferred from the callback and forwarded by store.mutate(...) and useMutation(...):
const createItem = mutation(async (ctx, label: string) => {
const res = await fetch("/api/items", {
method: "POST",
body: JSON.stringify({ label }),
});
const { id } = (await res.json()) as { id: string };
ctx.invalidate(listAtom);
return id;
});
const id = await store.mutate(createItem, "new row"); // id: stringimport { atom, mutation, createStore } from "@kdeveloper/kvark";
const listAtom = atom({
debugLabel: "list",
read: async () => {
const res = await fetch("/api/items");
return res.json() as Promise<string[]>;
},
});
const countAtom = atom({
debugLabel: "count",
read: async () => {
const res = await fetch("/api/count");
return (await res.json()) as { n: number };
},
});
const addItem = mutation(async (ctx, label: string) => {
ctx.writeOptimistic(listAtom, (prev) => [...(prev ?? []), label]);
ctx.writeOptimistic(countAtom, (prev) => ({ n: (prev?.n ?? 0) + 1 }));
await fetch("/api/items", {
method: "POST",
body: JSON.stringify({ label }),
});
ctx.invalidateMany([listAtom, countAtom]);
});
const store = createStore();
await store.mutate(addItem, "new row");React / Preact / Vue
import { useMutation } from "@kdeveloper/kvark/react"; // or "@kdeveloper/kvark/preact"
const runAddItem = useMutation(addItem);
await runAddItem("new row");<script setup lang="ts">
import { useMutation } from "@kdeveloper/kvark/vue";
const runAddItem = useMutation(addItem);
</script>useMutation returns a stable function (React/Preact: useCallback over store.mutate) with the same argument tuple as your mutation. If the mutation returns a value, that value is forwarded as the resolved promise:
const runCreate = useMutation(createItem);
const id = await runCreate("new row"); // id: stringWritable atoms vs onMount
Both can update an atom's cached value, but they serve different purposes:
| | write | onMount |
| ---------------- | --------------------------------------------- | -------------------------------------- |
| Triggered by | Explicit call (store.apply, useApplyAtom) | First subscriber mounts |
| After update | invalidate → refetch via read | No refetch — value stays as-is |
| Use case | Mutations, API calls, optimistic updates | Timers, subscriptions, imperative push |
onMount
Optional lifecycle hook that runs when the atom first gains a subscriber in a store (for example when a component using useAtomValue mounts). It receives a synchronous set(value) that marks the atom fresh and notifies listeners, plus a context object with ctx.global and ctx.refresh().
Use set(value) for timers, subscriptions, or imperative updates that should not go through read. Use ctx.refresh() to manually mark the current atom stale and re-run its read; it returns a Promise for the refreshed value and propagates read errors.
You may return a cleanup function; it runs when the last subscriber unsubscribes (for example when the last mounted consumer unmounts). If several components subscribe to the same atom, onMount runs once and the cleanup runs once after all of them unsubscribe.
const statusAtom = atom({
debugLabel: "status",
read: async () => fetch("/api/status").then((res) => res.json()),
onMount: (_set, ctx) => {
const id = setInterval(() => {
void ctx.refresh();
}, 30_000);
return () => clearInterval(id);
},
});Parallel loading
Declaring multiple dependencies causes the Store to start loading them in parallel before calling read. Inside read you control the parallelism explicitly.
const dashboardAtom = atom({
dependencies: { user: userAtom, settings: settingsAtom },
read: async (ctx) => {
const [user, settings] = await Promise.all([ctx.read("user"), ctx.read("settings")]);
return { user, settings };
},
});Stale-while-revalidate
By default (stalePolicy: 'keep'), invalidating an atom marks it stale while preserving the last value. Components keep rendering the old data without re-suspending. Once revalidation completes the atom becomes fresh again.
pending → fresh (first load)
fresh → stale (invalidated — old value kept)
stale → fresh (revalidation complete)
stale → error (revalidation failed — value preserved with 'keep')Use { observe: true } to access the isStale flag in your component:
function UserCard() {
const { value: user, isStale, error } = useAtomValue(userAtom, { observe: true });
return (
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<p>{user.name}</p>
{isStale && <Spinner />}
{error != null && <ErrorBanner error={error} />}
</div>
);
}Available stalePolicy values:
| Value | Behaviour |
| ------------------ | ---------------------------------------------- |
| 'keep' (default) | Show stale data, revalidate in background |
| 'suspend' | Re-throw Promise on revalidation (re-suspends) |
| 'reset' | Clear value and re-suspend immediately |
Auto refresh
Use refreshIntervalMs to poll an atom while it is actively subscribed. Every interval tick marks the atom stale and starts the same stale-while-revalidate read used by manual invalidation. The timer starts with the first subscriber and stops after the last unsubscribe or when the store is disposed.
const userAtom = atom({
refreshIntervalMs: 30_000,
read: async (ctx) => {
const res = await fetch("/api/user", { signal: ctx.signal });
return res.json();
},
});refreshIntervalMs must be a finite number greater than 0; invalid values are treated as disabled. Refresh uses setTimeout after each read completes, so slow requests do not stack up or start parallel refreshes. The same option is available in atomFamily, infinityAtom, and infinityAtomFamily.
Retry on error
By default a failed read immediately sets the atom to error state. You can opt into automatic retries with the retry and retryDelay options:
const userAtom = atom({
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
read: async (ctx) => {
const res = await fetch("/api/user", { signal: ctx.signal });
if (!res.ok) throw new Error("Failed to fetch user");
return res.json();
},
});| Option | Type | Default | Description |
| ------------ | ---------------------------------------------- | ------- | ------------------------------------------------------------------------------- |
| retry | boolean \| number | — | Number of additional attempts after the first failure. true defaults to 3 |
| retryDelay | number \| ((attemptIndex: number) => number) | 0 | Delay in ms before each retry. attemptIndex starts at 0 |
Retries respect AbortSignal — if the atom is invalidated or unmounted mid-retry the remaining attempts are cancelled. The atom stays in its previous state (no intermediate error flashes) until all retries are exhausted.
The same options are available in atomFamily and infinityAtom.
atomFamily
Create a family of atoms parametrised by a key. Members use read(param) and optional write(param) callbacks, are cached by param, and support LRU eviction.
import { atomFamily } from "@kdeveloper/kvark/family";
const postFamily = atomFamily({
debugLabel: "post",
cachePolicy: "lru",
lruSize: 50,
dependencies: (_postId: number) => ({ user: userAtom }),
read: (postId) => async (ctx) => {
const user = await ctx.read("user");
const res = await fetch(`/api/posts/${postId}?userId=${user.id}`, {
signal: ctx.signal,
});
return res.json();
},
});
// Use in a component
const post = useAtomValue(postFamily(42));
// Invalidate after a mutation
await api.updatePost(42, data);
postFamily.invalidate(42);
// Invalidate everything (e.g. on logout)
postFamily.invalidateAll();
// Cache helpers
postFamily.has(42);
postFamily.peek(42); // Atom<Post> | undefined, does not create a member
postFamily.remove(42);
postFamily.clear();atomFamily and infinityAtomFamily expose the same cache helper shape: has(param), peek(param), remove(param), clear(), and getCache(). peek/has inspect the current cache without creating a new atom; calling the family itself still creates or touches the member and updates LRU order.
atomFamily as a dependency
A dependencies entry can be either a regular Atom or an atomFamily. When the entry is a family, supply the param at the call site as the second positional argument to ctx.read. Each unique param maps to its own family member; reverse-dependency edges are wired up automatically, so invalidating any read member (or the whole family) propagates stale to the consumer.
import { atom } from "@kdeveloper/kvark";
import { atomFamily } from "@kdeveloper/kvark/family";
const userFamily = atomFamily({
read: (id: number) => async () => fetch(`/api/users/${id}`).then((r) => r.json()),
});
const profileAtom = atom({
dependencies: { user: userFamily },
read: async (ctx) => {
const user = await ctx.read("user", 7); // reads userFamily(7)
return `profile-of-${user.name}`;
},
});
// Invalidating a read family member cascades to consumers
userFamily.invalidate(7); // marks profileAtom staleThe signature of ctx.read is keyed on the dependency entry:
| Entry | Call form | Compile-time errors |
| ------------ | ---------------------- | ---------------------------------------------------- |
| Atom<V> | ctx.read(key) | passing a second arg → error |
| atomFamily | ctx.read(key, param) | omitting param → error; wrong param type → error |
const a = atom({
dependencies: { base: countAtom, user: userFamily },
read: async (ctx) => {
await ctx.read("base"); // ok
await ctx.read("user", 1); // ok
// @ts-expect-error family-key requires a parameter
ctx.read("user");
// @ts-expect-error plain atom-key does not accept a parameter
ctx.read("base", 1);
// @ts-expect-error param type must match the family's `Param`
ctx.read("user", "not-a-number");
},
});Repeated reads with the same param are deduplicated through the in-flight promise of the already-read family member; reads with different params use distinct atoms and run independently. Family-typed dependencies are not preloaded before read runs (the param is only known at the ctx.read call site), so unread members never trigger a fetch.
Object params and paramKey
By default atoms are cached by reference (===). When you want equality-by-value — for example when the param is a filter object — supply a paramKey function that maps the param to a stable primitive key:
import { atomFamily, stableFamilyKey } from "@kdeveloper/kvark/family";
const searchFamily = atomFamily({
// Two different object literals with the same fields → same atom
paramKey: (filters) => stableFamilyKey(filters),
read: (filters) => async () => fetchResults(filters),
});
searchFamily({ page: 1, query: "hello" }); // creates atom, key = '{"page":1,"query":"hello"}'
searchFamily({ query: "hello", page: 1 }); // returns the same atom (fields are sorted)The paramKey function is called once per family(param) invocation. The returned key is used for all cache operations (read, apply, invalidate, remove, LRU). The original param is still passed to read, write, and dependencies.
stableFamilyKey(value)
A built-in helper that serialises plain objects and arrays into a deterministic string:
- Object fields are sorted alphabetically at every nesting level.
- Supports:
string,number,boolean,null,undefined,bigint, plain objects and arrays (nested). - Not supported: class instances (other than plain
Object/Array),Map,Set,Symbol, functions, circular references. - Memoises by object reference via a
WeakMap— repeated calls with the same object reference are O(1). Treat param objects as immutable after the first call; mutating an object after it has been memoised will silently return the stale key.
Development-only stability check
In development builds (process.env.NODE_ENV !== "production"), atomFamily and infinityAtomFamily track how param maps to a cache key. If paramKey returns a different key for the same param reference, or if you call the family with two different object references that have the same content but no paramKey is provided, a console.warn is emitted explaining the problem. After 20 such warnings the family throws to interrupt what is almost certainly an infinite-loading loop. The check is fully removed by bundlers in production builds.
Batching (atomFamily with batch)
Instead of fetching each atom individually, you can batch multiple concurrent requests into a single call — inspired by @yornaath/batshit. Replace read with batch:
import { atomFamily, windowScheduler } from "@kdeveloper/kvark/family";
const userFamily = atomFamily({
debugLabel: "user",
dependencies: () => ({ auth: authAtom }),
batch: {
scheduler: windowScheduler(10),
fetch: async ({ keys, params, signal, ctx }) => {
const auth = await ctx.read("auth");
const res = await fetch(`/api/users?ids=${keys.join(",")}`, {
headers: { Authorization: auth.token },
signal,
});
const users: User[] = await res.json();
return new Map(users.map((u) => [u.id, u]));
},
},
});
// Each call returns an atom; concurrent reads within the scheduler
// window are batched into a single fetch.
const alice = useAtomValue(userFamily(1));
const bob = useAtomValue(userFamily(2));How it works: when multiple atoms are read concurrently (e.g. several components mount at once), each atom's read enqueues its key into the batch coordinator. The scheduler decides when to flush the queue: the default microtaskScheduler flushes at the end of the current microtask; windowScheduler(ms) waits up to ms milliseconds.
batch.fetch receives:
| Field | Type | Description |
| -------- | ------------------- | -------------------------------------------------------- |
| keys | readonly Key[] | Unique keys (after paramKey) accumulated in this batch |
| params | readonly Param[] | Corresponding original param values (same order) |
| signal | AbortSignal | Aborts when all individual atom signals are aborted |
| ctx | AtomContext<Deps> | Context from the first enqueued atom in the batch |
Return a Map<Key, Value>. Keys missing from the map cause an error for those atoms.
dependencies in batch mode
In batch mode, dependencies takes no param: () => Deps. This guarantees the same set of dependency atoms for every member of the family, making ctx safe to share across the entire batch. TypeScript enforces this at the type level — passing (param) => ... is a compile error when batch is present.
Schedulers
All schedulers are factory functions that return a BatchScheduler instance.
| Factory | Behaviour |
| ------------------------------------------- | ---------------------------------------------------------------------------------------- |
| microtaskScheduler() (default) | Flushes at the end of the current microtask (coalesces all synchronous enqueues) |
| windowScheduler(ms) | Flushes after ms milliseconds since the first enqueue in the window |
| maxBatchSizeScheduler(maxSize) | Flushes immediately when the number of unique keys reaches maxSize (no timer fallback) |
| windowedFiniteBatchScheduler(ms, maxSize) | Flushes on timer or when maxSize unique keys are reached — whichever comes first |
import { windowScheduler, windowedFiniteBatchScheduler } from "@kdeveloper/kvark/family";
// Flush after 10 ms or when 50 unique keys accumulate
const family = atomFamily({
batch: {
scheduler: windowedFiniteBatchScheduler(10, 50),
fetch: async ({ keys, signal }) => {
/* ... */
},
},
});infinityAtom
Create an atom for incrementally loaded ("infinite scroll") lists. Inspired by TanStack Query Infinite Queries, but using getNextCursor instead of getNextPageParam.
import { infinityAtom } from "@kdeveloper/kvark";
const projectsAtom = infinityAtom({
debugLabel: "projects",
initialCursor: 0,
queryFn: async ({ cursor, signal }) => {
const res = await fetch(`/api/projects?cursor=${cursor}`, { signal });
return res.json() as Promise<{ items: Project[]; nextCursor: number | null }>;
},
getNextCursor: (lastPage) => lastPage.nextCursor,
});The atom value is InfiniteData<Page, Cursor>:
type InfiniteData<Page, Cursor> = {
pages: Page[];
pageCursors: Cursor[];
hasNextPage: boolean;
isFetchingNextPage: boolean;
};Call store.apply(projectsAtom, "loadNext") (or useApplyAtom(projectsAtom)("loadNext")) to fetch and append the next page. If hasNextPage is false, the call is a no-op.
When the atom is invalidated, read re-fetches every cached page sequentially by replaying the stored pageCursors — the same stale-while-revalidate flow as regular atoms.
Use maxPages to cap the number of pages held in memory and re-fetched on invalidation:
const projectsAtom = infinityAtom({
initialCursor: 0,
queryFn: fetchProjectPage,
getNextCursor: (page) => page.nextCursor,
maxPages: 3,
});infinityAtomFamily
Parameterised version of infinityAtom. Combines the per-key cache and invalidation API of atomFamily with the paginated fetch flow of infinityAtom. Each param produces an independent WritableAtom<InfiniteData<Page, Cursor>, ["loadNext"]>, cached by param (or paramKey).
import { infinityAtomFamily } from "@kdeveloper/kvark";
const userPostsFamily = infinityAtomFamily({
debugLabel: "userPosts",
cachePolicy: "lru",
lruSize: 50,
initialCursor: () => 0,
queryFn:
(userId: number) =>
async ({ cursor, signal }) => {
const res = await fetch(`/api/users/${userId}/posts?cursor=${cursor}`, { signal });
return res.json() as Promise<{ items: Post[]; nextCursor: number | null }>;
},
getNextCursor: () => (page) => page.nextCursor,
});
const aliceFeed = useAtomValue(userPostsFamily(1));
const applyAliceFeed = useApplyAtom(userPostsFamily(1));
await applyAliceFeed("loadNext");
userPostsFamily.invalidate(1);
userPostsFamily.invalidateAll();The same family-level options as atomFamily are supported: cachePolicy: "lru" + lruSize, paramKey (with stableFamilyKey for object params), debugLabel, stalePolicy, retry, retryDelay, and dependencies: (param) => ({ ... }). The infinite-scroll options — initialCursor, queryFn, getNextCursor — are functions of param, while maxPages is shared by every member of the family.
React (@kdeveloper/kvark/react)
All hooks must be used inside a <Provider>.
useStore
Returns the Store instance from context — for advanced cases (e.g. calling store.read in async setup patterns) or when you need the store outside atom helpers.
useAtomValue
Reads an atom value and subscribes to updates. Suspends on first load.
// Basic — throws if error, suspends if pending
const user = useAtomValue(userAtom);
// Observed — exposes isStale and error without throwing
const { value, isStale, error } = useAtomValue(userAtom, { observe: true });AtomCancellationScope
React and Preact can abandon a Suspense render before effects commit. If useAtomValue starts an async read during that render, the component never subscribes, so there is no normal unmount cleanup for the in-flight ctx.signal.
Wrap replaceable Suspense screens in AtomCancellationScope and key the scope by the screen identity. When the key changes or the scope unmounts, Kvark aborts pending reads that were started by abandoned render branches and never reached a committed subscription.
import { Suspense } from "react";
import { AtomCancellationScope, Provider, useAtomValue } from "@kdeveloper/kvark/react";
function App({ reportName }: { reportName: string }) {
return (
<Provider store={store}>
<AtomCancellationScope key={reportName}>
<Suspense fallback="Loading report...">
<Report name={reportName} />
</Suspense>
</AtomCancellationScope>
</Provider>
);
}
function Report({ name }: { name: string }) {
const report = useAtomValue(reportFamily(name));
return <ReportView report={report} />;
}For Preact, import the same API from @kdeveloper/kvark/preact. Vue async setup already gets cleanup from Vue’s component/Suspense lifecycle; use await useAtomValue(atom) as shown in Suspense (Vue 3).
useApplyAtom
Returns a stable function that calls store.apply for a writable atom, without subscribing to the value.
const applyCount = useApplyAtom(countAtom);
await applyCount(42);useMutation
Returns a function that runs a mutation(...) descriptor against the store, with the same ...args as the mutation callback (after ctx). Does not subscribe to any atom. The resolved value is whatever the mutation callback returns (or void if it returns nothing).
const runReorder = useMutation(reorderMutation);
await runReorder(itemId, newIndex);
const runCreate = useMutation(createItem);
const id = await runCreate("new row");useAtom
Combines useAtomValue and useApplyAtom into a [value, apply] tuple.
const [count, applyCount] = useAtom(countAtom);useAtomContext
Imperative access to the StoreClient inside a callback. Does not subscribe.
const readBalance = useAtomContext(async (client) => {
return client.read(balanceAtom);
});
// Call imperatively, e.g. in an event handler
const balance = await readBalance();Preact (@kdeveloper/kvark/preact)
Same hook names, signatures, and behaviour as the React integration: Provider, AtomCancellationScope, useStore, useAtomValue, useApplyAtom, useMutation, useAtom, useAtomContext. All hooks must be used inside a <Provider>.
Internally the entry imports only from preact and preact/hooks — there is no dependency on preact/compat, so your app does not need any React compatibility aliases.
Vue 3 (@kdeveloper/kvark/vue)
Same composable names and behaviour as React: Provider, useStore, useAtomValue, useApplyAtom, useMutation, useAtom, useAtomContext. Wrap your app (or subtree) with Provider and pass :store="store".
Exported types: ThenableShallowRef<V> (default useAtomValue), ThenableObservedShallowRef<V> and ObservedValue<V> (with { observe: true }). They encode pending vs resolved ref shapes for TypeScript.
useAtomValue / useAtom expose values as awaitable shallow refs (PromiseLike) — in script code use .value; in templates Vue unwraps refs for you. await useAtomValue(atom) in an async setup suspends until the atom's first read resolves, integrating with <Suspense>.
Suspense (Vue 3)
Vue’s <Suspense> boundary applies to async components (e.g. async setup or top-level await in <script setup>), not to composables alone. Because useAtomValue and useAtom return PromiseLike refs, you can simply await them to suspend until the first read resolves.
atoms.ts
import { atom } from "@kdeveloper/kvark";
export const slowAtom = atom({
read: async () => {
await new Promise((r) => setTimeout(r, 50));
return "ready";
},
});AsyncRead.vue (under Provider; top-level await makes this component async for Suspense)
<script setup lang="ts">
import { useAtomValue } from "@kdeveloper/kvark/vue";
import { slowAtom } from "./atoms";
const value = await useAtomValue(slowAtom);
</script>
<template>
<div>{{ value }}</div>
</template>App.vue
<script setup lang="ts">
import { Suspense } from "vue";
import { createStore } from "@kdeveloper/kvark";
import { Provider } from "@kdeveloper/kvark/vue";
import AsyncRead from "./AsyncRead.vue";
const store = createStore();
</script>
<template>
<Provider :store="store">
<Suspense>
<template #default>
<AsyncRead />
</template>
<template #fallback>
<div>Loading…</div>
</template>
</Suspense>
</Provider>
</template>Alternatively, use defineComponent({ async setup() { ... } }) and await useAtomValue(slowAtom) before returning the render function — the same pattern as in test/vue/hooks.test.ts. useAtom is also awaitable: const [ref, apply] = await useAtom(writableAtom).
External Invalidation
StoreClient exposes the store's full capabilities outside your component tree — useful for WebSocket handlers, SSE streams, polling timers, and Service Workers.
import { createStore } from "@kdeveloper/kvark";
const store = createStore();
const client = store.getClient();
// WebSocket
const ws = new WebSocket("wss://api.example.com/events");
ws.addEventListener("message", (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "post.updated") {
postFamily.invalidate(msg.postId);
}
if (msg.type === "user.updated") {
client.invalidate(userAtom);
}
});
// SSE
const sse = new EventSource("/api/stream");
sse.addEventListener("prices.updated", () => {
client.invalidate(pricesAtom);
});
// Polling
setInterval(() => client.invalidate(statusAtom), 30_000);
// Subscribe to state changes outside components
const unsub = client.subscribe(userAtom, (state) => {
if (state.status === "fresh") {
analytics.identify(state.value.id);
}
});client.observe(atom, listener) is an explicit alias for state-bearing external subscriptions. client.subscribe remains available for compatibility and has the same state-bearing callback shape on StoreClient.
Cache-first reads
Use read(atom) when you want Kvark's normal async read semantics. For imperative code that prefers cached data when available, use:
const state = client.get(userAtom); // AtomState<User>, sync
const cached = client.peek(userAtom); // User | undefined, sync
const user = await client.ensure(userAtom); // cached fresh/stale value, otherwise read()get(atom) mirrors getSnapshot(atom). peek(atom) returns the current cached value from fresh, stale, or retained error states without starting a read. ensure(atom) resolves immediately for cached fresh/stale values, or for an error state that retained a non-undefined value; otherwise it delegates to read(atom).
Direct Write
When the server pushes a complete, authoritative value (e.g. via WebSocket or SSE), use store.write / client.write to store it directly — no refetch through read is triggered, and the atom's write callback is not invoked. Any in-flight read for the atom is aborted, and derived atoms that depend on it are marked stale so they re-compute.
ws.addEventListener("message", (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "post.updated") {
client.write(postFamily(msg.postId), msg.post);
}
});write also accepts a mutator function — just like React's setState. The callback receives the previous value (or undefined if the atom is still pending) and returns the next one:
client.write(counterAtom, (prev) => (prev ?? 0) + 1);If
Vitself is a function type, pass a mutator that returns it:write(atom, () => myFn).
write works on any Atom<V> — the atom does not need a write config. This makes it the right tool when you already have the final value and want to skip a redundant network round-trip. Use apply when you want to run the atom's async write callback instead.
set(atom, value | mutator) is an alias for write(atom, ...); run(atom, ...args) is an alias for apply(atom, ...args); runMutation(m, ...args) is an alias for mutate(m, ...args).
Inspect and dispose
For devtools and diagnostics, inspect(atom) returns lightweight metadata: label, current status, dependency counts, listener count, mount count, and whether a read promise is in flight.
Call dispose() when a store is scoped to a request, test, or worker lifetime. It aborts in-flight reads, calls active onMount cleanups, clears listeners, and unregisters family invalidation hooks. After disposal, store methods throw Store has been disposed; calling dispose() again is a no-op.
StoreClient interface
The concrete Store class exposes the same surface as getClient() — e.g. store.read(atom) / client.read(atom), store.apply(atom, ...args) / client.apply(atom, ...args), and store.mutate(m, ...args) / client.mutate(m, ...args).
import type { Atom, WritableAtom, AtomState, Mutation, StoreInspection } from "@kdeveloper/kvark";
interface StoreClient {
get<V>(atom: Atom<V>): AtomState<V>;
peek<V>(atom: Atom<V>): V | undefined;
ensure<V>(atom: Atom<V>): Promise<V>;
read<V>(atom: Atom<V>): Promise<V>;
run<V, A extends readonly unknown[]>(atom: WritableAtom<V, A>, ...args: A): Promise<void>;
apply<V, A extends readonly unknown[]>(atom: WritableAtom<V, A>, ...args: A): Promise<void>;
runMutation<Args extends readonly unknown[], Result>(
mutation: Mutation<Args, Result>,
...args: Args
): Promise<Result>;
mutate<Args extends readonly unknown[], Result>(
mutation: Mutation<Args, Result>,
...args: Args
): Promise<Result>;
set<V>(atom: Atom<V>, value: V): void;
set<V>(atom: Atom<V>, mutate: (prev: V | undefined) => V): void;
write<V>(atom: Atom<V>, value: V): void;
write<V>(atom: Atom<V>, mutate: (prev: V | undefined) => V): void;
invalidate(atom: Atom<unknown>): void;
invalidateMany(atoms: ReadonlyArray<Atom<unknown>>): void;
observe<V>(atom: Atom<V>, listener: (state: AtomState<V>) => void): () => void;
subscribe<V>(atom: Atom<V>, listener: (state: AtomState<V>) => void): () => void;
inspect(atom: Atom<unknown>): StoreInspection;
dispose(): void;
}Provider and Store Setup
React / Preact
// store.ts
import { createStore } from "@kdeveloper/kvark";
export const store = createStore();
// App.tsx
import { Provider } from "@kdeveloper/kvark/react"; // or "@kdeveloper/kvark/preact"
import { store } from "./store";
export function App() {
return (
<Provider store={store}>
<Router />
</Provider>
);
}Vue 3
<script setup lang="ts">
import { createStore } from "@kdeveloper/kvark";
import { Provider } from "@kdeveloper/kvark/vue";
const store = createStore();
</script>
<template>
<Provider :store="store">
<RouterView />
</Provider>
</template>Utility Types
import type { AtomValue, AtomArgs, IsWritable, WritableAtomContext } from "@kdeveloper/kvark";
type UserData = AtomValue<typeof userAtom>; // → User
type PostArgs = AtomArgs<typeof postAtom>; // → [postId: number]
type Writable = IsWritable<typeof countAtom>; // → true | falseWritableAtomContext extends MutationContext — the same writeOptimistic / invalidate / invalidateMany helpers appear on both standalone mutations and writable write callbacks.
Test Utilities
import { createTestStore, mockAtom, resolveAtom } from "@kdeveloper/kvark/tests";
const userMock = mockAtom(userAtom, { value: { id: "u1", name: "Ada" } });
const profileAtom = atom({
dependencies: { user: userAtom },
read: async (ctx) => ctx.read("user"),
});
const store = createTestStore({ dependencyOverrides: [[userAtom, userMock]] });
expect(await resolveAtom(store, profileAtom)).toEqual({ id: "u1", name: "Ada" });mockAtom also accepts { read: vi.fn(...) } / { read: jest.fn(...) } and { error }.
Use mockAtomFamily for family dependencies, setAtomValue(s) for seeded values, and
resolveAtom(s) to read typed atom values in tests.
Package Structure
| Import | Contents |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @kdeveloper/kvark | atom, infinityAtom, infinityAtomFamily, mutation, createStore, all types (including Store, StoreClient, StoreInspection, Mutation, MutationContext) |
| @kdeveloper/kvark/react | Provider, AtomCancellationScope, useStore, useAtomValue, useApplyAtom, useMutation, useAtom, useAtomContext |
| @kdeveloper/kvark/preact | Provider, AtomCancellationScope, useStore, useAtomValue, useApplyAtom, useMutation, useAtom, useAtomContext |
| @kdeveloper/kvark/vue | Provider, useStore, useAtomValue, useApplyAtom, useMutation, useAtom, useAtomContext; types ThenableShallowRef, ThenableObservedShallowRef, ObservedValue |
| @kdeveloper/kvark/family | atomFamily, infinityAtomFamily, stableFamilyKey, microtaskScheduler, windowScheduler, maxBatchSizeScheduler, windowedFiniteBatchScheduler, re-exports atom, infinityAtom, mutation, createStore; types include AtomFamily, InfinityAtomFamily, BatchScheduler, BatchFetchInput, Mutation, MutationContext, and core atom types |
| @kdeveloper/kvark/tests | Test helpers: createTestStore, mockAtom, mockAtomFamily, withAtomMocks, setAtomValue, setAtomValues, resolveAtom, resolveAtoms; store-level dependencyOverrides; framework-agnostic and compatible with Vitest/Jest mock functions |
The core (@kdeveloper/kvark) has zero runtime dependencies. React, Preact, and Vue are optional peer dependencies — install the framework you use and import from /react, /preact, or /vue.
The published package is ESM-only (import); there is no CommonJS require entry.
Development
This repo uses pnpm (see packageManager in package.json).
pnpm install
pnpm build # tsc --noEmit && tsdown (ESM + .d.ts + source maps)
pnpm test # vitest run
pnpm lint # oxlint
pnpm fmt # oxfmtUseful variants: pnpm test:watch, pnpm lint:fix, pnpm fmt:check, pnpm lint:types (TypeScript check only).
Requirements
- Node.js ≥ 20
- TypeScript ≥ 6 (strict mode recommended)
- React ≥ 18 (optional peer, for
@kdeveloper/kvark/react) - Preact ≥ 10 (optional peer, for
@kdeveloper/kvark/preact) - Vue ≥ 3.4 (optional peer, for
@kdeveloper/kvark/vue)
License
MIT
