npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@kdeveloper/kvark

v1.6.0

Published

Atomic state management with explicit dependency graphs

Readme

@kdeveloper/kvark

npm version

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.4

Quick 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 read method (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 in read).
  • ctx.signal — an AbortSignal tied 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 as stale and schedule a refetch. Does not participate in rollback; best called after the async work succeeds.
  • ctx.invalidateMany(atoms) — same as invalidate, but for multiple atoms at once.
  • ctx.writeOptimistic(atom, value | mutator) — synchronously write a value into another atom's cache (same semantics as store.write). If the mutation throws, the store rolls back all atoms touched by writeOptimistic together with the primary atom's setOptimisticValue.

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 as store.read(atom) / client.read(atom).
  • ctx.writeOptimistic(atom, value | mutator) — same semantics as inside write. On throw, every atom touched by writeOptimistic rolls 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: string
import { 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: string

Writable 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 stale

The 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 V itself 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 | false

WritableAtomContext 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         # oxfmt

Useful 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