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

Published

Atomic state management with explicit dependency graphs

Readme

@kdeveloper/kvark

npm version

Atomic state management for React, Preact, Vue 3, and Svelte 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, vue >=3.4, and/or svelte >=5

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, readonly ref with shallow values — use .value in <script setup> (templates unwrap refs automatically). Atom objects keep their original identity and are not converted into deep readonly proxies. Before await, the value is V | undefined. After await, a statically known "keep" atom (including the default policy) exposes V; "reset", "suspend", unknown policies, and getter sources expose V | undefined because they can become pending again. Awaiting it in an async setup waits for usable data and integrates with <Suspense> without refetching a fresh cache — 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, isPending, isStale, error), including the first load and failures (ThenableObservedShallowRef<V>). Use isPending to distinguish loading from an atom whose successful value is undefined.

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

Svelte

The Svelte entrypoint returns readable stores, so use $ store syntax in components. Wrap descendants with Provider, which creates a store for the component tree. Pass a store when you need custom store options.

<!-- App.svelte -->
<script lang="ts">
  import { Provider } from "@kdeveloper/kvark/svelte";
  import UserCard from "./UserCard.svelte";
</script>

<Provider>
  <UserCard />
</Provider>
<!-- UserCard.svelte -->
<script lang="ts">
  import { useAtomValue } from "@kdeveloper/kvark/svelte";
  import { userAtom } from "./atoms";

  const user = useAtomValue(userAtom);
</script>

{#if $user}
  <div>{$user.name}</div>
{/if}

Until the first read resolves, $user is undefined. It is also undefined during suspended/reset revalidation. With the default "keep" policy, a failed refresh preserves the last successful value. Readable stores have no render-time error channel; passing { observe: true } exposes ObservedValue (value, isPending, isStale, error) so the component can render loading and errors explicitly.

Moving between frameworks

Keep atom definitions, families, mutations, and store configuration unchanged. Change the integration import and the way the component reads reactive values:

| Operation | React / Preact | Vue | Svelte | | ------------------------------- | ----------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------- | | Read | useAtomValue(atom) | useAtomValue(atom) | useAtomValue(atom) | | Access the result | value | value.value in script; value in templates | $value | | Read and write | useAtom(atom) → [value, apply] | useAtom(atom) → [ref, apply] | useAtom(atom) → [readable, apply] | | Observe loading and errors | useAtomValue(atom, { observe: true }) | Same options; access the observed object through the ref | Same options; access the observed object through $ | | Write / mutate / access context | useApplyAtom, useMutation, useAtomContext | Same names, arguments, and promise results | Same names, arguments, and promise results |

For portable loading/error UI, prefer { observe: true }. Every integration exports the same ObservedValue<V> type:

type ObservedValue<V> = {
  value: V | undefined;
  isPending: boolean;
  isStale: boolean;
  error: unknown;
};

Observed reads never throw or suspend implicitly. The initial state has isPending: true; a failure is exposed in error, even when there is no previous value. A successful undefined has isPending: false. { observe: true } also works with useAtom in every framework.

// The call is identical in every framework.
const [state, apply] = useAtom(countAtom, { observe: true });
// Read state in React/Preact, state.value in Vue script, or $state in Svelte.

When porting a component, keep its isPending, isStale, and error branches and only adapt the reactive access syntax. For stalePolicy: "keep", old data stays visible with isStale: true. For "suspend", data is hidden with isPending: true and isStale: true. For "reset", data is cleared with isPending: true and isStale: false. Revalidation starts in the background in every integration.

Default reads follow each framework's rendering model. React/Preact suspend on pending data and throw read errors to an error boundary. Vue waits when explicitly awaited; errors from a plain ref read go through Vue's component error handling. Vue does not automatically re-enter <Suspense> after setup resolves, and Svelte readable stores do not provide implicit Suspense or error boundaries. Use observed state for equivalent loading/error UI across all four.

React/Preact hooks run again when props change. Vue/Svelte setup runs once, so pass a getter when the atom depends on reactive props:

// React / Preact
const state = useAtomValue(userFamily(userId), { observe: true });
// Vue / Svelte: the getter reads the current reactive prop
const state = useAtomValue(() => userFamily(props.userId), { observe: true });

Getters are supported by useAtomValue, useApplyAtom, and useAtom in Vue/Svelte. Changing the atom or Provider.store rebinds subscriptions and callbacks without remounting the consumer or resetting its local state. Cleanup releases the previous subscription and cancels unowned reads. useStore() returns the raw store at the time it is called; a saved raw instance remains tied to that store. Prefer the atom/mutation/context callbacks when the provider can change.

Pass store to Provider in every framework for the same setup. Svelte additionally supports an omitted store and setStore(store) / setStore(() => reactiveStore) during component initialization; its Provider disposes only the default store it creates.

Updating existing applications: React/Preact observed reads now expose the first pending/error state instead of relying on Suspense/error boundaries. Handle isPending and error, and guard nullable value. Vue/Svelte narrow awaited static "keep" atoms to V; resetting/suspending atoms and reactive getters stay nullable. Svelte plain readable errors preserve the cached value under "keep"; use observed reads to display those errors.

Both hooks are also awaitable: await useAtomValue(atom) returns a reactive Readable<V> for a statically known "keep" atom (including the default policy), and await useAtom(atom) returns [Readable<V>, apply]. After awaiting, a plain static "keep" readable preserves its last successful value on refresh errors and while a replacement Provider loads. Resetting/suspending atoms, unknown policies, and reactive getters remain Readable<V | undefined>; observed reads always expose nullable value alongside loading and errors. Awaiting reuses usable cached values; suspended stale data waits for revalidation. Initial read failures reject, including with { observe: true }. Pending awaits follow Provider/atom changes and release their reads when the component is destroyed.

For top-level await in Svelte 5, enable compilerOptions.experimental.async: true in your Svelte configuration and render the async child inside a <svelte:boundary> with a pending snippet:

<!-- Inside Provider -->
<svelte:boundary>
  <UserCard />
  {#snippet pending()}
    <p>Loading…</p>
  {/snippet}
</svelte:boundary>
<!-- UserCard.svelte -->
<script lang="ts">
  import { useAtomValue } from "@kdeveloper/kvark/svelte";
  import { userAtom } from "./atoms";

  const user = await useAtomValue(userAtom);
</script>

<div>{$user.name}</div>

For writable atoms, use const [count, applyCount] = await useAtom(countAtom) and keep using $count in markup. Exported types include ThenableReadable<V>, ThenableObservedReadable<V>, and ThenableAtomTuple<V, A>.

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.

EventSource updates

Use eventStream, eventAtom, and eventAtomFamily for authoritative live updates from a shared Server-Sent Events connection. read remains responsible for the initial load and manual invalidation; a set event writes the pushed value directly.

import { eventAtom, eventStream } from "@kdeveloper/kvark";

const events = eventStream({ url: "/api/events", withCredentials: true });

const pricesAtom = eventAtom({
  stream: events,
  topic: "prices",
  read: async () => (await fetch("/api/prices")).json() as Promise<Price[]>,
  decode: (value): Price[] => PriceListSchema.parse(value),
});

eventAtomFamily uses a stable string paramKey to route each update without sending the original parameter object over SSE:

const postFamily = eventAtomFamily({
  stream: events,
  topic: "posts",
  paramKey: ({ id }: { id: string }) => id,
  read:
    ({ id }) =>
    async () =>
      (await fetch(`/api/posts/${id}`)).json() as Promise<Post>,
  decode: (value): Post => PostSchema.parse(value),
});

The server sends the kvark event with protocol version 1. id must be a monotonically increasing non-negative integer for a given topic/key; older events are ignored.

event: kvark
id: 42
data: {"v":1,"topic":"posts","key":"post-42","op":"set","value":{"id":"post-42","title":"New title"}}

For a single eventAtom, omit key. To request a normal stale-while-revalidate reload instead of sending a full value, send {"v":1,"topic":"posts","key":"post-42","op":"invalidate"}. The server must authorize and filter events for the connected user/session; EventSource has no client-to-server subscription channel, so Kvark does not send dynamic topic or family-key subscriptions.

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.

An explicitly written undefined is a valid cached value and stays undefined on subsequent reads and updater calls. In custom read/write callbacks, ctx.hasPreviousValue() distinguishes this case from an atom that has no previous value yet; ctx.getPreviousValue() returns undefined in both cases.

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.

Rollback preserves newer authoritative writes, reads, and successful mutations. Overlapping failed optimistic mutations unwind without restoring another mutation's failed value. Derived atoms that computed an optimistic result are invalidated when their source rolls back.

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 / Svelte

import { useMutation } from "@kdeveloper/kvark/react"; // or /preact, /vue, /svelte

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, isPending, isStale, error } = useAtomValue(userAtom, { observe: true });

  if (isPending) return <Spinner />;
  if (user == null) return <ErrorBanner error={error} />;

  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' | Hide stale data; native React/Preact reads re-suspend | | 'reset' | Clear value immediately and expose pending state |

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. A store uses one scheduler timer for all of its subscribed refresh atoms; it 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 can also be a function when the next polling delay depends on the last read result:

const feedAtom = atom({
  refreshIntervalMs: ({ value, previousValue, error }) => {
    if (error != null) return 5_000;
    return value?.version === previousValue?.version ? 60_000 : 10_000;
  },
  read: async (ctx) => {
    const res = await fetch("/api/feed", { signal: ctx.signal });
    return res.json();
  },
});

refreshIntervalMs must resolve to a finite number greater than 0; invalid values, undefined, null, and false are treated as disabled. Function intervals are recomputed after each completed read, including manual read, invalidation-driven reads, and ctx.refresh(). The scheduler wakes at the nearest refresh deadline and reuses in-flight reads, so slow requests do not stack up or start parallel refreshes. The same option is available in atomFamily, infinityAtom, and infinityAtomFamily.

In browsers, interval refresh pauses while document.hidden is true or the window is unfocused. If an atom's interval becomes due while the application is inactive, the store performs one catch-up refresh only for overdue atoms after the document is visible and the window is focused again, then continues from the remaining deadlines. Manual read, invalidate, and ctx.refresh() calls still work while inactive.

You can pause interval refreshes for a specific atom in a specific store. Pausing only stops refreshIntervalMs polling; manual read, invalidate, and ctx.refresh() calls still work normally.

store.pause(userAtom);
store.resume(userAtom);

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.

When a retry fails while resolving a declared dependency, Kvark retries that dependency too. Successfully resolved dependencies are reused within the same read's retry sequence.

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.

Providing a required write callback returns a WritableAtomFamily: member calls, peek, and getCache preserve WritableAtom<Value, Args>, so store.apply(family(param), ...args) and framework setters infer the write arguments without casts. When the configuration's write is optional, members remain typed as read-only atoms.

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

Reading a member registers its family with the store even when the member has no direct subscriber. Parameterized dependency edges are rebuilt for each read attempt: after a consumer switches from member 7 to 8, invalidating 7 no longer affects that consumer unless the latest attempt also reads 7.

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.
  • Object fields whose value is undefined are treated as absent, so generated API params remain stable when optional fields are omitted or explicitly undefined.
  • 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.

Batches are isolated by store, including when stores share the same application context object. A shared family never combines different stores' dependencies or results into one fetch or stream.

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> | First member's global/previous value; shared signal and dependency reads for all live members |

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.

Streaming batches

Use batch.stream, batch.initialValue, and batch.reduce to route each yielded Map<Key, Chunk> to its member atoms. batch.bufferSize limits queued chunks per member to 64 by default and accepts a positive safe integer. When a member's buffer fills, the shared producer waits until that member consumes a chunk or cancels; chunks are preserved in order. A slow member can therefore pause the whole batch. Cancellation or an early iterator return releases blocked producers; the upstream signal aborts when all members stop consuming.

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. Default reads suspend on first load; observed reads expose loading and errors as data.

// Basic — throws if error, suspends if pending
const user = useAtomValue(userAtom);

// Observed — never implicitly throws or suspends; value is V | undefined
const { value, isPending, 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);

// The same observe option is supported by every integration.
const [state, applyObservedCount] = useAtom(countAtom, { observe: true });

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 observed-state contract as React: Provider, useStore, useAtomValue, useApplyAtom, useMutation, useAtom, useAtomContext. Wrap your app (or subtree) with Provider and pass :store="store". Default reads use Vue refs and explicit awaiting; see Moving between frameworks for native rendering differences.

Exported types: ThenableShallowRef<V, Policy> (default useAtomValue), ThenableObservedShallowRef<V> and ObservedValue<V> (with { observe: true }). They encode pending vs resolved ref shapes for TypeScript. Atom<V, Policy> and WritableAtom<V, Args, Policy> preserve stale policy through ordinary, stream, family, infinity, and event factories. AtomStalePolicy<typeof atom> extracts it. An annotation such as Atom<V> intentionally erases the policy; use Atom<V, "keep"> in wrappers that need to preserve a known policy.

useAtomValue / useAtom expose values as awaitable readonly refs with shallow values (PromiseLike) — in script code use .value; in templates Vue unwraps refs for you. await useAtomValue(atom) in an async setup waits for usable data, integrating with <Suspense>. Fresh data resolves from the cache; stale data under "keep" resolves immediately while revalidating in the background. An observed await resolves to an error-bearing ref when loading fails; a plain await rejects.

The resolved ref for a static "keep" atom has .value: V. After a successful await it preserves the last usable value while a replacement Provider store loads, then updates to the new store's data. Errors still throw. Getter sources and resetting/suspending atoms remain V | undefined; observed state always reports loading explicitly. V itself can include null or undefined — awaiting does not remove values the atom legitimately returns.

const userAtom = atom({ read: async () => ({ name: "Ada" }) }); // default: keep
const user = await useAtomValue(userAtom);
user.value.name; // string; no undefined guard required

const resettingAtom = atom({ stalePolicy: "reset", read: async () => ({ name: "Ada" }) });
const resettingUser = await useAtomValue(resettingAtom);
resettingUser.value?.name; // the live ref can become undefined after invalidation

const selectedUser = await useAtomValue(() => userFamily(props.userId));
selectedUser.value?.name; // changing the source can start a new load

await useAtom(writableAtom) applies the same policy-based narrowing to the tuple's ref and preserves the setter's argument types.

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.

If a cleanup throws, the remaining cleanups still run and the store releases its caches before throwing an AggregateError containing the cleanup errors.

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

impor