@kdeveloper/kvark
v0.10.1
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({ get, set?, dependencies? }) |
| Async model | Optional | get, set, ctx.get — 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 | store.set() | 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, useAtomValue, useSetAtom } from "@kdeveloper/kvark/react";
// 1. Create atoms
const userIdAtom = atom({
debugLabel: "userId",
get: async () => 1,
set: async (_ctx, _id: number) => {},
});
const userAtom = atom({
debugLabel: "user",
dependencies: { userId: userIdAtom },
get: async (ctx) => {
const id = await ctx.get("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, useAtomValue, useSetAtom } 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 get 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 get 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",
get: async () => 1,
set: async () => {},
});
export const userAtom = atom({
debugLabel: "user",
dependencies: { userId: userIdAtom },
get: async (ctx) => {
const id = await ctx.get("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).
Core Concepts
Atoms
An atom is the smallest unit of state. Its get function is always async, and dependencies must be declared explicitly.
import { atom } from "@kdeveloper/kvark";
// Primitive atom — no dependencies
const countAtom = atom({
debugLabel: "count",
get: async () => 0,
set: async (_ctx, _value: number) => {},
});
// Derived atom — reads from another atom
const doubleAtom = atom({
debugLabel: "double",
dependencies: { count: countAtom },
get: async (ctx) => {
const n = await ctx.get("count");
return n * 2;
},
});Writable atoms
An atom is writable when its config includes a set function. The return type becomes WritableAtom<Value, Args>, where Args is the tuple of arguments the setter accepts.
const profileAtom = atom({
debugLabel: "profile",
get: async () => {
const res = await fetch("/api/profile");
return res.json() as Promise<Profile>;
},
set: async (ctx, patch: Partial<Profile>) => {
await fetch("/api/profile", {
method: "PATCH",
body: JSON.stringify(patch),
signal: ctx.signal,
});
},
});Lifecycle after set: the store calls config.set(ctx, ...args), waits for the returned promise, then calls invalidate on the atom. This marks it stale and triggers a background refetch via get — the same stale-while-revalidate flow described above.
The ctx passed to set provides:
ctx.get(key)— read any declared dependency (same as inget).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 set 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",
get: async () => {
const res = await fetch("/api/todo");
return res.json() as Promise<Todo>;
},
set: 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:
set: 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 get had loaded before the optimistic update — no manual rollback needed.
Dependent mutations
The ctx inside set 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",
get: async () => {
const res = await fetch("/api/todos");
return res.json() as Promise<Todo[]>;
},
});
const todoAtom = atomFamily({
debugLabel: "todo",
get: (id: number) => async () => {
const res = await fetch(`/api/todos/${id}`);
return res.json() as Promise<Todo>;
},
set: (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 set, but without setOptimisticValue, ctx.get, or ctx.signal — pass server inputs as mutation arguments, and use store.get / client.get beforehand if you need current values):
ctx.writeOptimistic(atom, value | mutator)— same semantics as insideset. 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 set, a successful mutate does not invalidate any atom automatically. Call ctx.invalidate / ctx.invalidateMany when you want stale-while-revalidate after the request completes.
import { atom, mutation, createStore } from "@kdeveloper/kvark";
const listAtom = atom({
debugLabel: "list",
get: async () => {
const res = await fetch("/api/items");
return res.json() as Promise<string[]>;
},
});
const countAtom = atom({
debugLabel: "count",
get: 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.
Writable atoms vs onMount
Both can update an atom's cached value, but they serve different purposes:
| | set | onMount |
| ---------------- | ----------------------------------------- | -------------------------------------- |
| Triggered by | Explicit call (store.set, useSetAtom) | First subscriber mounts |
| After update | invalidate → refetch via get | 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 — useful for timers, subscriptions, or imperative updates that should not go through get.
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 clockAtom = atom({
debugLabel: "clock",
get: async () => new Date().toISOString(),
onMount: (set) => {
const id = setInterval(() => {
set(new Date().toISOString());
}, 1000);
return () => clearInterval(id);
},
});Parallel loading
Declaring multiple dependencies causes the Store to resolve them in parallel before calling get. Inside get you control the parallelism explicitly.
const dashboardAtom = atom({
dependencies: { user: userAtom, settings: settingsAtom },
get: async (ctx) => {
const [user, settings] = await Promise.all([ctx.get("user"), ctx.get("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 |
Retry on error
By default a failed get 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),
get: 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. Atoms are cached by param; supports LRU eviction.
import { atomFamily } from "@kdeveloper/kvark/family";
const postFamily = atomFamily({
debugLabel: "post",
cachePolicy: "lru",
lruSize: 50,
dependencies: (_postId: number) => ({ user: userAtom }),
get: (postId) => async (ctx) => {
const user = await ctx.get("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();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),
get: (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 (get, set, invalidate, remove, LRU). The original param is still passed to get, set, 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.
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 get 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.get("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 resolves 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 resolved concurrently (e.g. several components mount at once), each atom's get 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.set(projectsAtom, "loadNext") (or useSetAtom(projectsAtom)("loadNext")) to fetch and append the next page. If hasNextPage is false, the call is a no-op.
When the atom is invalidated, get 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,
});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.resolve 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 });useSetAtom
Returns the setter for a writable atom, without subscribing to the value.
const setCount = useSetAtom(countAtom);
await setCount(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.
const runReorder = useMutation(reorderMutation);
await runReorder(itemId, newIndex);useAtom
Combines useAtomValue and useSetAtom into a [value, setter] tuple.
const [count, setCount] = useAtom(countAtom);useAtomContext
Imperative access to the StoreClient inside a callback. Does not subscribe.
const readBalance = useAtomContext(async (client) => {
return client.get(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, useStore, useAtomValue, useSetAtom, 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, useSetAtom, 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 get 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 get resolves.
atoms.ts
import { atom } from "@kdeveloper/kvark";
export const slowAtom = atom({
get: 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, set] = 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);
}
});Direct Write
When the server pushes a complete, authoritative value (e.g. via WebSocket or SSE), use client.write to store it directly — no refetch through get is triggered. Any in-flight get 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 set config. This makes it the right tool when you already have the final value and want to skip a redundant network round-trip.
StoreClient interface
The concrete Store class exposes the same surface as getClient() — e.g. store.mutate(m, ...args) and client.mutate(m, ...args).
import type { Atom, WritableAtom, AtomState, Mutation } from "@kdeveloper/kvark";
interface StoreClient {
get<V>(atom: Atom<V>): Promise<V>;
set<V, A extends readonly unknown[]>(atom: WritableAtom<V, A>, ...args: A): Promise<void>;
mutate<Args extends readonly unknown[]>(mutation: Mutation<Args>, ...args: Args): Promise<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;
subscribe<V>(atom: Atom<V>, listener: (state: AtomState<V>) => void): () => 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 set callbacks.
Package Structure
| Import | Contents |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @kdeveloper/kvark | atom, mutation, createStore, all types (including Mutation, MutationContext) |
| @kdeveloper/kvark/react | Provider, useStore, useAtomValue, useSetAtom, useMutation, useAtom, useAtomContext |
| @kdeveloper/kvark/preact | Provider, useStore, useAtomValue, useSetAtom, useMutation, useAtom, useAtomContext |
| @kdeveloper/kvark/vue | Provider, useStore, useAtomValue, useSetAtom, useMutation, useAtom, useAtomContext; types ThenableShallowRef, ThenableObservedShallowRef, ObservedValue |
| @kdeveloper/kvark/family | atomFamily, stableFamilyKey, microtaskScheduler, windowScheduler, maxBatchSizeScheduler, windowedFiniteBatchScheduler, re-exports atom, mutation, createStore; types include AtomFamily, BatchScheduler, BatchFetchInput, Mutation, MutationContext, and core atom types |
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
