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

v0.10.1

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({ get, set?, dependencies? }) | | Async model | Optional | get, set, ctx.get — always async | | Parallel loading | Manual Promise.all | Built-in through dependencies | | Stale-while-revalidate | Re-suspends on revalidation | Shows stale data; Suspense only on first load | | atomFamily | External (jotai/utils) | Core, with LRU and invalidation | | External invalidation | store.set() | Explicit store.invalidate() / StoreClient | | TypeScript strictness | strict: true recommended | strict: true + 8 extra flags, any forbidden |

Installation

pnpm add @kdeveloper/kvark
# optional peers: react >=18, preact >=10, and/or vue >=3.4

Quick Start

import { atom, createStore } from "@kdeveloper/kvark";
import { Provider, useAtomValue, useSetAtom } from "@kdeveloper/kvark/react";

// 1. Create atoms
const userIdAtom = atom({
  debugLabel: "userId",
  get: async () => 1,
  set: async (_ctx, _id: number) => {},
});

const userAtom = atom({
  debugLabel: "user",
  dependencies: { userId: userIdAtom },
  get: async (ctx) => {
    const id = await ctx.get("userId");
    const res = await fetch(`/api/users/${id}`, { signal: ctx.signal });
    return res.json();
  },
});

// 2. Create a store
const store = createStore();

// 3. Wrap your app
function App() {
  return (
    <Provider store={store}>
      <Suspense fallback="Loading...">
        <UserCard />
      </Suspense>
    </Provider>
  );
}

// 4. Use in components
function UserCard() {
  const user = useAtomValue(userAtom);
  return <div>{user.name}</div>;
}

Preact

The same atoms and store work with @kdeveloper/kvark/preact — the API is identical to the React integration. Replace the React import with the Preact one:

import { Provider, useAtomValue, useSetAtom } from "@kdeveloper/kvark/preact";

Hooks use only preact and preact/hooks internally — no preact/compat aliases required.

Vue 3

The same atoms and store work with @kdeveloper/kvark/vue. Composables mirror the React hooks; useAtomValue returns an awaitable shallowRef — use .value in <script setup> (templates unwrap refs automatically). Until the first get resolves, that ref’s value is undefined; TypeScript types it as V | undefined (ThenableShallowRef<V>). After await useAtomValue(atom) you get Readonly<ShallowRef<V>> with a defined .value. The ref also implements PromiseLike, so awaiting it in an async setup suspends until the first get resolves — see Suspense (Vue 3).

Composables must run inside a Provider subtree. Put useAtomValue / useAtom in a child component (or a nested route), not in the same component that renders Provider without passing the store through a wrapper.

atoms.ts — lift the atom definitions from the Quick Start into a shared module:

import { atom } from "@kdeveloper/kvark";

export const userIdAtom = atom({
  debugLabel: "userId",
  get: async () => 1,
  set: async () => {},
});

export const userAtom = atom({
  debugLabel: "user",
  dependencies: { userId: userIdAtom },
  get: async (ctx) => {
    const id = await ctx.get("userId");
    const res = await fetch(`/api/users/${id}`, { signal: ctx.signal });
    return res.json();
  },
});

UserCard.vue

<script setup lang="ts">
import { useAtomValue } from "@kdeveloper/kvark/vue";
import { userAtom } from "./atoms";

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

<template>
  <div>{{ user?.name }}</div>
</template>

While the atom is pending, the unwrapped ref is undefined, so {{ user.name }} would throw at runtime and TypeScript flags user.name in script. Use optional chaining (as above), v-if="user", or await useAtomValue(userAtom) with <Suspense>.

With useAtomValue(atom, { observe: true }), the ref always holds an ObservedValue object (value, isStale, error); value is V | undefined while pending (ThenableObservedShallowRef<V>).

App.vue

<script setup lang="ts">
import { createStore } from "@kdeveloper/kvark";
import { Provider } from "@kdeveloper/kvark/vue";
import UserCard from "./UserCard.vue";

const store = createStore();
</script>

<template>
  <Provider :store="store">
    <UserCard />
  </Provider>
</template>

Unlike React, Vue’s <Suspense> only activates for async child components. Since useAtomValue and useAtom return PromiseLike refs, just await them in an async setup (or top-level <script setup>) to trigger Suspense — see Suspense (Vue 3).

Core Concepts

Atoms

An atom is the smallest unit of state. Its get function is always async, and dependencies must be declared explicitly.

import { atom } from "@kdeveloper/kvark";

// Primitive atom — no dependencies
const countAtom = atom({
  debugLabel: "count",
  get: async () => 0,
  set: async (_ctx, _value: number) => {},
});

// Derived atom — reads from another atom
const doubleAtom = atom({
  debugLabel: "double",
  dependencies: { count: countAtom },
  get: async (ctx) => {
    const n = await ctx.get("count");
    return n * 2;
  },
});

Writable atoms

An atom is writable when its config includes a set function. The return type becomes WritableAtom<Value, Args>, where Args is the tuple of arguments the setter accepts.

const profileAtom = atom({
  debugLabel: "profile",
  get: async () => {
    const res = await fetch("/api/profile");
    return res.json() as Promise<Profile>;
  },
  set: async (ctx, patch: Partial<Profile>) => {
    await fetch("/api/profile", {
      method: "PATCH",
      body: JSON.stringify(patch),
      signal: ctx.signal,
    });
  },
});

Lifecycle after set: the store calls config.set(ctx, ...args), waits for the returned promise, then calls invalidate on the atom. This marks it stale and triggers a background refetch via get — the same stale-while-revalidate flow described above.

The ctx passed to set provides:

  • ctx.get(key) — read any declared dependency (same as in get).
  • 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 set to immediately reflect the new value in the UI while the mutation runs in the background. If the mutation throws (or the signal aborts), the store automatically rolls back to the state captured before the first setOptimisticValue call. Derived atoms that depend on this atom are marked stale so they re-render too.

const todoAtom = atom({
  debugLabel: "todo",
  get: async () => {
    const res = await fetch("/api/todo");
    return res.json() as Promise<Todo>;
  },
  set: async (ctx, title: string) => {
    ctx.setOptimisticValue({ title, done: false });
    await fetch("/api/todo", {
      method: "PUT",
      body: JSON.stringify({ title }),
      signal: ctx.signal,
    });
  },
});

setOptimisticValue also accepts a mutator function — the callback receives the previous value (or undefined if pending) and returns the next one:

set: async (ctx) => {
  ctx.setOptimisticValue((prev) => ({ ...prev, loading: true }));
  await fetch("/api/update", { signal: ctx.signal });
},

If the PUT fails, the atom reverts to whatever value get had loaded before the optimistic update — no manual rollback needed.

Dependent mutations

The ctx inside set also provides methods for cross-atom side-effects:

  • ctx.invalidate(atom) — mark another atom 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",
  get: async () => {
    const res = await fetch("/api/todos");
    return res.json() as Promise<Todo[]>;
  },
});

const todoAtom = atomFamily({
  debugLabel: "todo",
  get: (id: number) => async () => {
    const res = await fetch(`/api/todos/${id}`);
    return res.json() as Promise<Todo>;
  },
  set: (id: number) => async (ctx, patch: Partial<Todo>) => {
    ctx.setOptimisticValue((prev) => ({ ...prev!, ...patch }));
    ctx.writeOptimistic(todosListAtom, (list) =>
      list?.map((t) => (t.id === id ? { ...t, ...patch } : t)),
    );
    await fetch(`/api/todos/${id}`, {
      method: "PATCH",
      body: JSON.stringify(patch),
      signal: ctx.signal,
    });
    ctx.invalidate(todosListAtom);
  },
});

If the PATCH fails, both the item and the list revert to their pre-optimistic state. After success, invalidate(todosListAtom) triggers a background refetch to reconcile with the server.

Standalone mutations (mutation)

Sometimes a server action updates several atoms at once, and there is no single writable atom that should own the mutation. Use mutation(fn) to define that work once, then run it with store.mutate(m, ...args) or useMutation(m) in components.

The callback receives a MutationContext (the same optimistic and invalidation helpers as in set, but without setOptimisticValue, ctx.get, or ctx.signal — pass server inputs as mutation arguments, and use store.get / client.get beforehand if you need current values):

  • ctx.writeOptimistic(atom, value | mutator) — same semantics as inside set. 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 set, a successful mutate does not invalidate any atom automatically. Call ctx.invalidate / ctx.invalidateMany when you want stale-while-revalidate after the request completes.

import { atom, mutation, createStore } from "@kdeveloper/kvark";

const listAtom = atom({
  debugLabel: "list",
  get: async () => {
    const res = await fetch("/api/items");
    return res.json() as Promise<string[]>;
  },
});

const countAtom = atom({
  debugLabel: "count",
  get: async () => {
    const res = await fetch("/api/count");
    return (await res.json()) as { n: number };
  },
});

const addItem = mutation(async (ctx, label: string) => {
  ctx.writeOptimistic(listAtom, (prev) => [...(prev ?? []), label]);
  ctx.writeOptimistic(countAtom, (prev) => ({ n: (prev?.n ?? 0) + 1 }));
  await fetch("/api/items", {
    method: "POST",
    body: JSON.stringify({ label }),
  });
  ctx.invalidateMany([listAtom, countAtom]);
});

const store = createStore();
await store.mutate(addItem, "new row");

React / Preact / Vue

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

const runAddItem = useMutation(addItem);

await runAddItem("new row");
<script setup lang="ts">
import { useMutation } from "@kdeveloper/kvark/vue";

const runAddItem = useMutation(addItem);
</script>

useMutation returns a stable function (React/Preact: useCallback over store.mutate) with the same argument tuple as your mutation.

Writable atoms vs onMount

Both can update an atom's cached value, but they serve different purposes:

| | set | onMount | | ---------------- | ----------------------------------------- | -------------------------------------- | | Triggered by | Explicit call (store.set, useSetAtom) | First subscriber mounts | | After update | invalidate → refetch via get | No refetch — value stays as-is | | Use case | Mutations, API calls, optimistic updates | Timers, subscriptions, imperative push |

onMount

Optional lifecycle hook that runs when the atom first gains a subscriber in a store (for example when a component using useAtomValue mounts). It receives a synchronous set(value) that marks the atom fresh and notifies listeners — useful for timers, subscriptions, or imperative updates that should not go through get.

You may return a cleanup function; it runs when the last subscriber unsubscribes (for example when the last mounted consumer unmounts). If several components subscribe to the same atom, onMount runs once and the cleanup runs once after all of them unsubscribe.

const clockAtom = atom({
  debugLabel: "clock",
  get: async () => new Date().toISOString(),
  onMount: (set) => {
    const id = setInterval(() => {
      set(new Date().toISOString());
    }, 1000);
    return () => clearInterval(id);
  },
});

Parallel loading

Declaring multiple dependencies causes the Store to resolve them in parallel before calling get. Inside get you control the parallelism explicitly.

const dashboardAtom = atom({
  dependencies: { user: userAtom, settings: settingsAtom },
  get: async (ctx) => {
    const [user, settings] = await Promise.all([ctx.get("user"), ctx.get("settings")]);
    return { user, settings };
  },
});

Stale-while-revalidate

By default (stalePolicy: 'keep'), invalidating an atom marks it stale while preserving the last value. Components keep rendering the old data without re-suspending. Once revalidation completes the atom becomes fresh again.

pending  →  fresh     (first load)
fresh    →  stale     (invalidated — old value kept)
stale    →  fresh     (revalidation complete)
stale    →  error     (revalidation failed — value preserved with 'keep')

Use { observe: true } to access the isStale flag in your component:

function UserCard() {
  const { value: user, isStale, error } = useAtomValue(userAtom, { observe: true });

  return (
    <div style={{ opacity: isStale ? 0.6 : 1 }}>
      <p>{user.name}</p>
      {isStale && <Spinner />}
      {error != null && <ErrorBanner error={error} />}
    </div>
  );
}

Available stalePolicy values:

| Value | Behaviour | | ------------------ | ---------------------------------------------- | | 'keep' (default) | Show stale data, revalidate in background | | 'suspend' | Re-throw Promise on revalidation (re-suspends) | | 'reset' | Clear value and re-suspend immediately |

Retry on error

By default a failed get immediately sets the atom to error state. You can opt into automatic retries with the retry and retryDelay options:

const userAtom = atom({
  retry: 3,
  retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
  get: async (ctx) => {
    const res = await fetch("/api/user", { signal: ctx.signal });
    if (!res.ok) throw new Error("Failed to fetch user");
    return res.json();
  },
});

| Option | Type | Default | Description | | ------------ | ---------------------------------------------- | ------- | ------------------------------------------------------------------------------- | | retry | boolean \| number | — | Number of additional attempts after the first failure. true defaults to 3 | | retryDelay | number \| ((attemptIndex: number) => number) | 0 | Delay in ms before each retry. attemptIndex starts at 0 |

Retries respect AbortSignal — if the atom is invalidated or unmounted mid-retry the remaining attempts are cancelled. The atom stays in its previous state (no intermediate error flashes) until all retries are exhausted.

The same options are available in atomFamily and infinityAtom.

atomFamily

Create a family of atoms parametrised by a key. Atoms are cached by param; supports LRU eviction.

import { atomFamily } from "@kdeveloper/kvark/family";

const postFamily = atomFamily({
  debugLabel: "post",
  cachePolicy: "lru",
  lruSize: 50,
  dependencies: (_postId: number) => ({ user: userAtom }),
  get: (postId) => async (ctx) => {
    const user = await ctx.get("user");
    const res = await fetch(`/api/posts/${postId}?userId=${user.id}`, {
      signal: ctx.signal,
    });
    return res.json();
  },
});

// Use in a component
const post = useAtomValue(postFamily(42));

// Invalidate after a mutation
await api.updatePost(42, data);
postFamily.invalidate(42);

// Invalidate everything (e.g. on logout)
postFamily.invalidateAll();

Object params and paramKey

By default atoms are cached by reference (===). When you want equality-by-value — for example when the param is a filter object — supply a paramKey function that maps the param to a stable primitive key:

import { atomFamily, stableFamilyKey } from "@kdeveloper/kvark/family";

const searchFamily = atomFamily({
  // Two different object literals with the same fields → same atom
  paramKey: (filters) => stableFamilyKey(filters),
  get: (filters) => async () => fetchResults(filters),
});

searchFamily({ page: 1, query: "hello" }); // creates atom, key = '{"page":1,"query":"hello"}'
searchFamily({ query: "hello", page: 1 }); // returns the same atom (fields are sorted)

The paramKey function is called once per family(param) invocation. The returned key is used for all cache operations (get, set, invalidate, remove, LRU). The original param is still passed to get, set, and dependencies.

stableFamilyKey(value)

A built-in helper that serialises plain objects and arrays into a deterministic string:

  • Object fields are sorted alphabetically at every nesting level.
  • Supports: string, number, boolean, null, undefined, bigint, plain objects and arrays (nested).
  • Not supported: class instances (other than plain Object/Array), Map, Set, Symbol, functions, circular references.
  • Memoises by object reference via a WeakMap — repeated calls with the same object reference are O(1). Treat param objects as immutable after the first call; mutating an object after it has been memoised will silently return the stale key.

Batching (atomFamily with batch)

Instead of fetching each atom individually, you can batch multiple concurrent requests into a single call — inspired by @yornaath/batshit. Replace get with batch:

import { atomFamily, windowScheduler } from "@kdeveloper/kvark/family";

const userFamily = atomFamily({
  debugLabel: "user",
  dependencies: () => ({ auth: authAtom }),
  batch: {
    scheduler: windowScheduler(10),
    fetch: async ({ keys, params, signal, ctx }) => {
      const auth = await ctx.get("auth");
      const res = await fetch(`/api/users?ids=${keys.join(",")}`, {
        headers: { Authorization: auth.token },
        signal,
      });
      const users: User[] = await res.json();
      return new Map(users.map((u) => [u.id, u]));
    },
  },
});

// Each call returns an atom; concurrent resolves within the scheduler
// window are batched into a single fetch.
const alice = useAtomValue(userFamily(1));
const bob = useAtomValue(userFamily(2));

How it works: when multiple atoms are resolved concurrently (e.g. several components mount at once), each atom's get enqueues its key into the batch coordinator. The scheduler decides when to flush the queue: the default microtaskScheduler flushes at the end of the current microtask; windowScheduler(ms) waits up to ms milliseconds.

batch.fetch receives:

| Field | Type | Description | | -------- | ------------------- | -------------------------------------------------------- | | keys | readonly Key[] | Unique keys (after paramKey) accumulated in this batch | | params | readonly Param[] | Corresponding original param values (same order) | | signal | AbortSignal | Aborts when all individual atom signals are aborted | | ctx | AtomContext<Deps> | Context from the first enqueued atom in the batch |

Return a Map<Key, Value>. Keys missing from the map cause an error for those atoms.

dependencies in batch mode

In batch mode, dependencies takes no param: () => Deps. This guarantees the same set of dependency atoms for every member of the family, making ctx safe to share across the entire batch. TypeScript enforces this at the type level — passing (param) => ... is a compile error when batch is present.

Schedulers

All schedulers are factory functions that return a BatchScheduler instance.

| Factory | Behaviour | | ------------------------------------------- | ---------------------------------------------------------------------------------------- | | microtaskScheduler() (default) | Flushes at the end of the current microtask (coalesces all synchronous enqueues) | | windowScheduler(ms) | Flushes after ms milliseconds since the first enqueue in the window | | maxBatchSizeScheduler(maxSize) | Flushes immediately when the number of unique keys reaches maxSize (no timer fallback) | | windowedFiniteBatchScheduler(ms, maxSize) | Flushes on timer or when maxSize unique keys are reached — whichever comes first |

import { windowScheduler, windowedFiniteBatchScheduler } from "@kdeveloper/kvark/family";

// Flush after 10 ms or when 50 unique keys accumulate
const family = atomFamily({
  batch: {
    scheduler: windowedFiniteBatchScheduler(10, 50),
    fetch: async ({ keys, signal }) => {
      /* ... */
    },
  },
});

infinityAtom

Create an atom for incrementally loaded ("infinite scroll") lists. Inspired by TanStack Query Infinite Queries, but using getNextCursor instead of getNextPageParam.

import { infinityAtom } from "@kdeveloper/kvark";

const projectsAtom = infinityAtom({
  debugLabel: "projects",
  initialCursor: 0,
  queryFn: async ({ cursor, signal }) => {
    const res = await fetch(`/api/projects?cursor=${cursor}`, { signal });
    return res.json() as Promise<{ items: Project[]; nextCursor: number | null }>;
  },
  getNextCursor: (lastPage) => lastPage.nextCursor,
});

The atom value is InfiniteData<Page, Cursor>:

type InfiniteData<Page, Cursor> = {
  pages: Page[];
  pageCursors: Cursor[];
  hasNextPage: boolean;
  isFetchingNextPage: boolean;
};

Call store.set(projectsAtom, "loadNext") (or useSetAtom(projectsAtom)("loadNext")) to fetch and append the next page. If hasNextPage is false, the call is a no-op.

When the atom is invalidated, get re-fetches every cached page sequentially by replaying the stored pageCursors — the same stale-while-revalidate flow as regular atoms.

Use maxPages to cap the number of pages held in memory and re-fetched on invalidation:

const projectsAtom = infinityAtom({
  initialCursor: 0,
  queryFn: fetchProjectPage,
  getNextCursor: (page) => page.nextCursor,
  maxPages: 3,
});

React (@kdeveloper/kvark/react)

All hooks must be used inside a <Provider>.

useStore

Returns the Store instance from context — for advanced cases (e.g. calling store.resolve in async setup patterns) or when you need the store outside atom helpers.

useAtomValue

Reads an atom value and subscribes to updates. Suspends on first load.

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

// Observed — exposes isStale and error without throwing
const { value, isStale, error } = useAtomValue(userAtom, { observe: true });

useSetAtom

Returns the setter for a writable atom, without subscribing to the value.

const setCount = useSetAtom(countAtom);
await setCount(42);

useMutation

Returns a function that runs a mutation(...) descriptor against the store, with the same ...args as the mutation callback (after ctx). Does not subscribe to any atom.

const runReorder = useMutation(reorderMutation);
await runReorder(itemId, newIndex);

useAtom

Combines useAtomValue and useSetAtom into a [value, setter] tuple.

const [count, setCount] = useAtom(countAtom);

useAtomContext

Imperative access to the StoreClient inside a callback. Does not subscribe.

const readBalance = useAtomContext(async (client) => {
  return client.get(balanceAtom);
});

// Call imperatively, e.g. in an event handler
const balance = await readBalance();

Preact (@kdeveloper/kvark/preact)

Same hook names, signatures, and behaviour as the React integration: Provider, useStore, useAtomValue, useSetAtom, useMutation, useAtom, useAtomContext. All hooks must be used inside a <Provider>.

Internally the entry imports only from preact and preact/hooks — there is no dependency on preact/compat, so your app does not need any React compatibility aliases.

Vue 3 (@kdeveloper/kvark/vue)

Same composable names and behaviour as React: Provider, useStore, useAtomValue, useSetAtom, useMutation, useAtom, useAtomContext. Wrap your app (or subtree) with Provider and pass :store="store".

Exported types: ThenableShallowRef<V> (default useAtomValue), ThenableObservedShallowRef<V> and ObservedValue<V> (with { observe: true }). They encode pending vs resolved ref shapes for TypeScript.

useAtomValue / useAtom expose values as awaitable shallow refs (PromiseLike) — in script code use .value; in templates Vue unwraps refs for you. await useAtomValue(atom) in an async setup suspends until the atom's first get resolves, integrating with <Suspense>.

Suspense (Vue 3)

Vue’s <Suspense> boundary applies to async components (e.g. async setup or top-level await in <script setup>), not to composables alone. Because useAtomValue and useAtom return PromiseLike refs, you can simply await them to suspend until the first get resolves.

atoms.ts

import { atom } from "@kdeveloper/kvark";

export const slowAtom = atom({
  get: async () => {
    await new Promise((r) => setTimeout(r, 50));
    return "ready";
  },
});

AsyncRead.vue (under Provider; top-level await makes this component async for Suspense)

<script setup lang="ts">
import { useAtomValue } from "@kdeveloper/kvark/vue";
import { slowAtom } from "./atoms";

const value = await useAtomValue(slowAtom);
</script>

<template>
  <div>{{ value }}</div>
</template>

App.vue

<script setup lang="ts">
import { Suspense } from "vue";
import { createStore } from "@kdeveloper/kvark";
import { Provider } from "@kdeveloper/kvark/vue";
import AsyncRead from "./AsyncRead.vue";

const store = createStore();
</script>

<template>
  <Provider :store="store">
    <Suspense>
      <template #default>
        <AsyncRead />
      </template>
      <template #fallback>
        <div>Loading…</div>
      </template>
    </Suspense>
  </Provider>
</template>

Alternatively, use defineComponent({ async setup() { ... } }) and await useAtomValue(slowAtom) before returning the render function — the same pattern as in test/vue/hooks.test.ts. useAtom is also awaitable: const [ref, set] = await useAtom(writableAtom).

External Invalidation

StoreClient exposes the store's full capabilities outside your component tree — useful for WebSocket handlers, SSE streams, polling timers, and Service Workers.

import { createStore } from "@kdeveloper/kvark";

const store = createStore();
const client = store.getClient();

// WebSocket
const ws = new WebSocket("wss://api.example.com/events");
ws.addEventListener("message", (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === "post.updated") {
    postFamily.invalidate(msg.postId);
  }
  if (msg.type === "user.updated") {
    client.invalidate(userAtom);
  }
});

// SSE
const sse = new EventSource("/api/stream");
sse.addEventListener("prices.updated", () => {
  client.invalidate(pricesAtom);
});

// Polling
setInterval(() => client.invalidate(statusAtom), 30_000);

// Subscribe to state changes outside components
const unsub = client.subscribe(userAtom, (state) => {
  if (state.status === "fresh") {
    analytics.identify(state.value.id);
  }
});

Direct Write

When the server pushes a complete, authoritative value (e.g. via WebSocket or SSE), use client.write to store it directly — no refetch through get is triggered. Any in-flight get for the atom is aborted, and derived atoms that depend on it are marked stale so they re-compute.

ws.addEventListener("message", (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === "post.updated") {
    client.write(postFamily(msg.postId), msg.post);
  }
});

write also accepts a mutator function — just like React's setState. The callback receives the previous value (or undefined if the atom is still pending) and returns the next one:

client.write(counterAtom, (prev) => (prev ?? 0) + 1);

If 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 set config. This makes it the right tool when you already have the final value and want to skip a redundant network round-trip.

StoreClient interface

The concrete Store class exposes the same surface as getClient() — e.g. store.mutate(m, ...args) and client.mutate(m, ...args).

import type { Atom, WritableAtom, AtomState, Mutation } from "@kdeveloper/kvark";

interface StoreClient {
  get<V>(atom: Atom<V>): Promise<V>;
  set<V, A extends readonly unknown[]>(atom: WritableAtom<V, A>, ...args: A): Promise<void>;
  mutate<Args extends readonly unknown[]>(mutation: Mutation<Args>, ...args: Args): Promise<void>;
  write<V>(atom: Atom<V>, value: V): void;
  write<V>(atom: Atom<V>, mutate: (prev: V | undefined) => V): void;
  invalidate(atom: Atom<unknown>): void;
  invalidateMany(atoms: ReadonlyArray<Atom<unknown>>): void;
  subscribe<V>(atom: Atom<V>, listener: (state: AtomState<V>) => void): () => void;
}

Provider and Store Setup

React / Preact

// store.ts
import { createStore } from "@kdeveloper/kvark";
export const store = createStore();

// App.tsx
import { Provider } from "@kdeveloper/kvark/react"; // or "@kdeveloper/kvark/preact"
import { store } from "./store";

export function App() {
  return (
    <Provider store={store}>
      <Router />
    </Provider>
  );
}

Vue 3

<script setup lang="ts">
import { createStore } from "@kdeveloper/kvark";
import { Provider } from "@kdeveloper/kvark/vue";

const store = createStore();
</script>

<template>
  <Provider :store="store">
    <RouterView />
  </Provider>
</template>

Utility Types

import type { AtomValue, AtomArgs, IsWritable, WritableAtomContext } from "@kdeveloper/kvark";

type UserData = AtomValue<typeof userAtom>; // → User
type PostArgs = AtomArgs<typeof postAtom>; // → [postId: number]
type Writable = IsWritable<typeof countAtom>; // → true | false

WritableAtomContext extends MutationContext — the same writeOptimistic / invalidate / invalidateMany helpers appear on both standalone mutations and writable set callbacks.

Package Structure

| Import | Contents | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | @kdeveloper/kvark | atom, mutation, createStore, all types (including Mutation, MutationContext) | | @kdeveloper/kvark/react | Provider, useStore, useAtomValue, useSetAtom, useMutation, useAtom, useAtomContext | | @kdeveloper/kvark/preact | Provider, useStore, useAtomValue, useSetAtom, useMutation, useAtom, useAtomContext | | @kdeveloper/kvark/vue | Provider, useStore, useAtomValue, useSetAtom, useMutation, useAtom, useAtomContext; types ThenableShallowRef, ThenableObservedShallowRef, ObservedValue | | @kdeveloper/kvark/family | atomFamily, stableFamilyKey, microtaskScheduler, windowScheduler, maxBatchSizeScheduler, windowedFiniteBatchScheduler, re-exports atom, mutation, createStore; types include AtomFamily, BatchScheduler, BatchFetchInput, Mutation, MutationContext, and core atom types |

The core (@kdeveloper/kvark) has zero runtime dependencies. React, Preact, and Vue are optional peer dependencies — install the framework you use and import from /react, /preact, or /vue.

The published package is ESM-only (import); there is no CommonJS require entry.

Development

This repo uses pnpm (see packageManager in package.json).

pnpm install
pnpm build       # tsc --noEmit && tsdown (ESM + .d.ts + source maps)
pnpm test        # vitest run
pnpm lint        # oxlint
pnpm fmt         # 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