npm package: @kdeveloper/kvark
Description: Atomic state management with explicit dependency graphs
Latest version: 0.10.1

# @kdeveloper/kvark

[![npm version](https://img.shields.io/npm/v/@kdeveloper/kvark.svg)](https://www.npmjs.com/package/@kdeveloper/kvark)

Atomic state management for **React**, **Preact**, and **Vue 3** with **explicit dependency graphs**, stale-while-revalidate, and first-class external invalidation.

Inspired by Jotai, but built around a key difference: dependencies are declared upfront rather than inferred at runtime. This makes the data graph statically analysable, enables parallel loading, and allows invalidation from anywhere — WebSocket handlers, SSE streams, timers, Service Workers — without framework hooks.

## Why Kvark?

|                        | Jotai                            | Kvark                                           |
| ---------------------- | -------------------------------- | ----------------------------------------------- |
| Dependency declaration | Implicit (via `get(atom)` calls) | Explicit, via `dependencies` field              |
| `atom()` signature     | `atom(read, write?)`             | `atom({ get, set?, dependencies? })`            |
| Async model            | Optional                         | `get`, `set`, `ctx.get` — always `async`        |
| Parallel loading       | Manual `Promise.all`             | Built-in through `dependencies`                 |
| Stale-while-revalidate | Re-suspends on revalidation      | Shows stale data; Suspense only on first load   |
| `atomFamily`           | External (`jotai/utils`)         | Core, with LRU and invalidation                 |
| External invalidation  | `store.set()`                    | Explicit `store.invalidate()` / `StoreClient`   |
| TypeScript strictness  | `strict: true` recommended       | `strict: true` + 8 extra flags, `any` forbidden |

## Installation

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

## Quick Start

```tsx
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:

```tsx
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`](https://vuejs.org/api/reactivity-advanced.html#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)](#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:

```ts
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`

```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`

```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)](#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.

```ts
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.

```ts
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](#stale-while-revalidate).

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](#dependent-mutations)). Also accepts a mutator.
- **`ctx.invalidate(atom)`** / **`ctx.invalidateMany(atoms)`** — mark unrelated atoms as stale so they refetch (see [Dependent mutations](#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.

```ts
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:

```ts
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:

```ts
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.

```ts
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**

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

const runAddItem = useMutation(addItem);

await runAddItem("new row");
```

```vue
<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.

```ts
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.

```ts
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:

```tsx
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:

```ts
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.

```ts
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:

```ts
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`](https://www.npmjs.com/package/@yornaath/batshit). Replace `get` with `batch`:

```ts
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   |

```ts
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](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries), but using `getNextCursor` instead of `getNextPageParam`.

```ts
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>`:

```ts
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:

```ts
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.

```tsx
// 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.

```tsx
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.

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

### `useAtom`

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

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

### `useAtomContext`

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

```tsx
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>`](https://vuejs.org/guide/built-ins/suspense.html#async-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`

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

```vue
<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`

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

```ts
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.

```ts
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:

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

```ts
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**

```tsx
// 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**

```vue
<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

```ts
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](https://pnpm.io) (see `packageManager` in `package.json`).

```bash
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