docs / Runtime
Data Fetching
useFetch and its static variants, createResource, mutate, HttpError / TimeoutError, and SSR data handoff.
Vesk's data layer is built on reactive resources. A resource represents an async value derived from a fetcher: it exposes live `loading`, `error` and `data` state, dedupes in-flight requests by key, retries failed GETs with exponential backoff, honours a client cache via `staleTime`, and hands payloads fetched on the server to the client so hydration never re-fetches.
The Resource object
`useFetch` and `createResource` both return a `Resource<T>`. It is a `PromiseLike<T>` (`await resource` resolves to `data` and rejects with `resource.error`), and its state fields are reactive — the compiler can branch on them in `if (res.loading)` / `if (res.error)` guards inside component bodies.
| Member | Behavior |
|---|---|
| loading | `true` while the fetcher (or a `refresh()`) is in flight. |
| error | `null` on success; an `HttpError`, `TimeoutError`, abort, or any value thrown by the fetcher. |
| data | Last successfully fetched payload, or `undefined` before the first success. |
| refresh() | Re-runs the fetcher for every handle sharing the same `key`, aborting the in-flight request first. |
| abort() | Aborts the in-flight request (client only; no-op on the server). |
| then / catch / finally | `await resource` resolves to `data` and rejects with `error`. |
useFetch
`useFetch<T>(urlOrFn, options?)` fetches a URL and parses the response as JSON. It is auto-imported inside components. With a string URL, the URL itself becomes the `key`; with a function fetcher, pass an explicit `key` so dedupe, cache and SSR handoff can identify it. On the server the request is run before the HTML is emitted and the payload is stashed for the client; on the client the payload is reused instead of re-fetching.
import { useFetch } from '@vesk/runtime';
interface Post {
id: number;
title: string;
}
component PostList() {
const posts = useFetch<Post[]>('/api/posts', {
key: 'posts',
staleTime: 60_000,
keepPreviousData: true,
});
if (posts.loading) return <p>Loading...</p>;
if (posts.error) return <p>{(posts.error as Error).message}</p>;
<ul>
// posts.data is undefined until the first success — iterate a fallback, or
// the render throws on the server where there is no second pass.
for (const post of posts.data ?? []) {
<li>{post.title}</li>
}
</ul>
}
`staleTime` only affects the client cache: after a successful fetch the payload is kept in `globalThis.__vsk_fetch_cache` and reused without a network request while `Date.now() - fetchedAt < staleTime`. It never affects server rendering.
into: writing the payload into a tracked cell
Give `useFetch` an `into` target and the payload is written into a tracked cell via `set()` the moment it lands — no `await resource`, no `.data` lookups. The cell is the single source of truth: `loading` and `error` still read from the returned `Resource`, but the payload renders straight from the cell, so any code that reads the cell (a loop, a `derived`, another component passed the cell) stays in sync automatically.
The target is the second member of a TrackDecl. `const &[posts, postsCell] = track<Post[]>([]);` gives you `posts` (the auto-tracked binding you read and write directly) plus `postsCell`, the raw `Tracked<Post[]>` that `into` expects. The cell keeps its initial value until the first success, so initialize it to the empty list to avoid a null-check.
import { useFetch } from '@vesk/runtime';
interface Post {
id: number;
title: string;
}
component PostList() {
const posts = useFetch<Post[]>('/api/posts', {
key: 'posts',
staleTime: 60_000,
keepPreviousData: true,
});
if (posts.loading) return <p>Loading...</p>;
if (posts.error) return <p>{(posts.error as Error).message}</p>;
<ul>
// posts.data is undefined until the first success — iterate a fallback, or
// the render throws on the server where there is no second pass.
for (const post of posts.data ?? []) {
<li>{post.title}</li>
}
</ul>
}
- `res.loading`/`res.error` describe the load; the payload lives in `posts`, so reads like `posts.length` and `{posts.map(...)}` are reactive on the cell.
- `res.refresh()` re-runs the fetcher and lands the fresh payload into the same cell; `res.abort()` cancels the in-flight request.
- The cell is written on both server and client, so the `<ul>` renders from the same value during SSR and after hydration.
- The cell survives the resource's lifecycle — pass it to a child component or read it from a `derived` and it reflects the latest payload without plumbing the `Resource` around.
Because the payload is written into the cell rather than read through the `Resource`, the component can also write to `posts` directly (e.g. an optimistic delete before a `refresh()`); the next settle overwrites the cell with fresh data.
Options
`UseFetchOptions<T>` extends `RequestInit` (minus `body`) with Vesk-specific controls. The same options object is accepted by `createResource`.
| Option | Type | Default | Behavior |
|---|---|---|---|
| key | string | the URL string (or fetcher source) | Identity for dedupe, client cache and SSR handoff; all handles sharing a key refresh together. |
| into | Tracked<T> | — | Target tracked cell; the payload is written into it via `set()`, so `await` is unnecessary and the cell re-renders. |
| body | unknown | — | Objects/arrays are `JSON.stringify`'d with `Content-Type: application/json`; string, FormData, URLSearchParams, Blob and ArrayBuffer pass through. |
| method | string | GET | HTTP method — retries only ever apply to GET. |
| headers | HeadersInit | — | Passed through to fetch; a plain object, array, or `Headers`. |
| staleTime | number | 0 | Client cache TTL in ms; while `Date.now() - fetchedAt < staleTime` the cached payload is reused. `0` always fetches. |
| keepPreviousData | boolean | false | Keep the previous `data` visible while a refresh is in flight instead of resetting to `undefined`. |
| retry | number | 0 | Retry count for failed GETs. Errors with HTTP status 400–499 are never retried. |
| retryDelay | number | 1000 | Base delay in ms; the client uses exponential backoff (`delay * 2^attempt`), the server a constant delay. |
| timeout | number | 0 | Abort the request and fail with a `TimeoutError` after this many ms. `0` disables it. |
| enabled | boolean | true | When `false` the resource starts idle (`loading: false`) and issues no fetch until `refresh()`. |
| dedupe | boolean | true | Multiple resources created with the same key share one in-flight request. |
Any remaining `RequestInit` fields pass straight through: `credentials`, `cache`, `mode`, `redirect`, `referrer`, `referrerPolicy`, `integrity`, `keepalive` and `signal`.
import { useFetch } from '@vesk/runtime';
interface Post {
id: number;
title: string;
}
component PostList() {
const posts = useFetch<Post[]>('/api/posts', {
key: 'posts',
staleTime: 60_000,
keepPreviousData: true,
});
if (posts.loading) return <p>Loading...</p>;
if (posts.error) return <p>{(posts.error as Error).message}</p>;
<ul>
// posts.data is undefined until the first success — iterate a fallback, or
// the render throws on the server where there is no second pass.
for (const post of posts.data ?? []) {
<li>{post.title}</li>
}
</ul>
}
Static helpers
- `useFetch.json<T>(url, options?)` — explicit JSON reader (same as plain `useFetch`).
- `useFetch.text(url, options?)` — reads the body as a string (`Resource<string>`).
- `useFetch.arrayBuffer(url, options?)` — reads the body as an `ArrayBuffer`.
Each static helper accepts the same `UseFetchOptions` and returns a `Resource`. They default their `key` to the URL, like plain `useFetch`.
import { useFetch } from '@vesk/runtime';
interface Post {
id: number;
title: string;
}
component PostList() {
const posts = useFetch<Post[]>('/api/posts', {
key: 'posts',
staleTime: 60_000,
keepPreviousData: true,
});
if (posts.loading) return <p>Loading...</p>;
if (posts.error) return <p>{(posts.error as Error).message}</p>;
<ul>
// posts.data is undefined until the first success — iterate a fallback, or
// the render throws on the server where there is no second pass.
for (const post of posts.data ?? []) {
<li>{post.title}</li>
}
</ul>
}
useFetch.stream
`useFetch.stream(urlOrFn, options?)` consumes a response body chunk by chunk and writes the running total into a tracked cell via `into`, so anything subscribed to that cell re-renders as data arrives. An optional `onChunk(chunk, total)` callback fires after each chunk. `urlOrFn` may be a provider function `() => string` that is re-evaluated on every fetch (including `refresh()`), so switching a tracked path propagates without recreating the resource. The URL must reference an API route that returns `text/plain` or streams plain text.
import { useFetch } from '@vesk/runtime';
interface Post {
id: number;
title: string;
}
component PostList() {
const posts = useFetch<Post[]>('/api/posts', {
key: 'posts',
staleTime: 60_000,
keepPreviousData: true,
});
if (posts.loading) return <p>Loading...</p>;
if (posts.error) return <p>{(posts.error as Error).message}</p>;
<ul>
// posts.data is undefined until the first success — iterate a fallback, or
// the render throws on the server where there is no second pass.
for (const post of posts.data ?? []) {
<li>{post.title}</li>
}
</ul>
}
createResource
`createResource<T>(fn, key?, into?, options?)` wraps an arbitrary async function as a resource — use it when the data comes from something other than a fetch (a computation, a socket, an SDK call) or when you want to control HTTP handling yourself (unlike `useFetch` it does not check `res.ok`, so a 404 is fetched data unless your fetcher throws). `key` is also derived from `options.key` or the fetcher source when omitted.
import { useFetch } from '@vesk/runtime';
interface Post {
id: number;
title: string;
}
component PostList() {
const posts = useFetch<Post[]>('/api/posts', {
key: 'posts',
staleTime: 60_000,
keepPreviousData: true,
});
if (posts.loading) return <p>Loading...</p>;
if (posts.error) return <p>{(posts.error as Error).message}</p>;
<ul>
// posts.data is undefined until the first success — iterate a fallback, or
// the render throws on the server where there is no second pass.
for (const post of posts.data ?? []) {
<li>{post.title}</li>
}
</ul>
}
The third argument is `into` (a raw tracked cell), and the fourth is the full `UseFetchOptions`. With `into`, the payload lands in the cell and the component renders from the cell directly:
import { useFetch } from '@vesk/runtime';
interface Post {
id: number;
title: string;
}
component PostList() {
const posts = useFetch<Post[]>('/api/posts', {
key: 'posts',
staleTime: 60_000,
keepPreviousData: true,
});
if (posts.loading) return <p>Loading...</p>;
if (posts.error) return <p>{(posts.error as Error).message}</p>;
<ul>
// posts.data is undefined until the first success — iterate a fallback, or
// the render throws on the server where there is no second pass.
for (const post of posts.data ?? []) {
<li>{post.title}</li>
}
</ul>
}
mutate
`mutate(key, data?)` updates the client-side cache and every live resource registered under `key` (client only — it is a no-op on the server). With `data`, it writes the cache and settles all handles to `{ loading: false, error: null, data }` without a network request — a classic optimistic update. Without `data`, it revalidates: the in-flight request is aborted and every handle re-runs its fetcher.
import { mutate } from '@vesk/runtime/src/resource';
// optimistic write — no refetch
mutate('posts', { id: 999, title: 'Local preview' });
// revalidate — refetch every live handle for the key
mutate('posts');
import { useFetch } from '@vesk/runtime';
interface Post {
id: number;
title: string;
}
component PostList() {
const posts = useFetch<Post[]>('/api/posts', {
key: 'posts',
staleTime: 60_000,
keepPreviousData: true,
});
if (posts.loading) return <p>Loading...</p>;
if (posts.error) return <p>{(posts.error as Error).message}</p>;
<ul>
// posts.data is undefined until the first success — iterate a fallback, or
// the render throws on the server where there is no second pass.
for (const post of posts.data ?? []) {
<li>{post.title}</li>
}
</ul>
}
Error handling
A failed fetch never throws synchronously — the error lands on `resource.error` and `await resource` rejects with it. `useFetch` throws an `HttpError` whenever `res.ok` is false; a `timeout` rejects with a `TimeoutError` and aborts the underlying request at the same time. An `abort()` or destroyed block surfaces an abort error.
| Type | Shape | When |
|---|---|---|
| HttpError | `status`, `statusCode`, message `HTTP {status}: {statusText}` (e.g. `HTTP 404: Not Found`) | Any non-ok response from `useFetch`. |
| TimeoutError | message `Request timed out after {ms}ms` | The `timeout` timer elapses before the request settles. |
| AbortError | `DOMException` or `Error` named `AbortError` | `abort()` is called, or the owning component is destroyed mid-flight. |
`HttpError`, `TimeoutError` and `mutate` live in `@vesk/runtime/src/resource` — import them from the module subpath.
import { useFetch } from '@vesk/runtime';
interface Post {
id: number;
title: string;
}
component PostList() {
const posts = useFetch<Post[]>('/api/posts', {
key: 'posts',
staleTime: 60_000,
keepPreviousData: true,
});
if (posts.loading) return <p>Loading...</p>;
if (posts.error) return <p>{(posts.error as Error).message}</p>;
<ul>
// posts.data is undefined until the first success — iterate a fallback, or
// the render throws on the server where there is no second pass.
for (const post of posts.data ?? []) {
<li>{post.title}</li>
}
</ul>
}
Retries only apply to GET requests, and an `HttpError` with status 400–499 is never retried. On the client the retry delay is exponential (`retryDelay * 2^attempt`); on the server it is a constant `retryDelay`.
SSR data handoff
Every resource keyed the same on both sides is handed off automatically: the server runs the request, stashes the payload with `setSsrData(key, value)` into `globalThis.__vsk_ssr_data`, and serializes it into the HTML; on the client `getSsrData(key)` settles the resource instantly, so hydration does not re-fetch. Failed SSR requests are memoized per render token so the server's re-render passes settle from the recorded error instead of re-firing a request that always fails (e.g. a 401).
For data fetched outside a resource (e.g. a top-level loader) you can use the same handoff APIs directly. They are exported from `@vesk/runtime`; `setSsrSink` additionally requires the server entry (`@vesk/runtime/server`).
import { setSsrData, clearSsrData, resolveSsrResources } from '@vesk/runtime';
export async function loadPageData() {
const user = await fetchUser();
setSsrData('user', user);
const posts = await fetchPosts();
setSsrData('posts', posts);
// snapshot { user, posts } after awaiting in-flight resource requests too
const serialized = await resolveSsrResources();
clearSsrData();
return serialized;
}
- `setSsrData(key, value)` — stash a value for client hydration.
- `getSsrData(key)` — read a stashed value (client reuse during hydration).
- `clearSsrData()` — wipe the handoff store.
- `resolveSsrResources()` — await all pending SSR resource requests and return the flat `{ [key]: value }` snapshot.
- `setSsrSink(sink)` — install a custom `SsrDataSink` (`set`/`get`/`snapshot`/`clear`); the compiler's SSR store installs one per request so data never leaks across renders.
`setSsrSink` and the `SsrDataSink` interface are server-only: `@vesk/runtime/server` exports them, the default client entry does not.
For data that belongs to a `client` island, pass `{ ssr: 'defer' }`: the fetch starts during SSR but stays out of the settle barrier, so the document is not held for data the client fetches on hydration anyway. See the Metadata page for the full trade-off.