New to Fetchit?
Start with the Overview for a quick introduction and installation, then come back here for in-depth usage patterns.
HTTP Client
createApi() returns a thin HTTP client built on the native fetch API.
Creating a Client
import { createApi } from '@vielzeug/fetchit';
const api = createApi({
baseUrl: 'https://api.example.com',
timeout: 30_000, // default: 30 000 ms
headers: { Authorization: 'Bearer token' },
fetch: globalThis.fetch, // optional
});
// Disable timeouts explicitly when needed
const noTimeoutApi = createApi({ timeout: Infinity });HTTP Methods
All methods return Promise<T> with the deserialized response body.
// GET
const users = await api.get<User[]>('/users');
// POST — plain object body is serialized to JSON automatically
const created = await api.post<User>('/users', { body: { name: 'Alice' } });
// PUT / PATCH / DELETE
const updated = await api.put<User>('/users/1', { body: { name: 'Alice Smith' } });
const patched = await api.patch<User>('/users/1', { body: { email: 'new@example.com' } });
await api.delete('/users/1');
// Custom method
const info = await api.request<Info>('OPTIONS', '/users');Type-Safe Path Parameters
{param} placeholders are extracted at compile time — TypeScript errors if a required param is missing or misspelled.
// Single param
const user = await api.get<User>('/users/{id}', { params: { id: 42 } });
// → GET /users/42
// Multiple params — TypeScript enforces all are provided
const comment = await api.get<Comment>('/posts/{postId}/comments/{commentId}', {
params: { postId: 1, commentId: 99 },
});
// → GET /posts/1/comments/99Query String Parameters
const page = await api.get<User[]>('/users', {
query: { role: 'admin', page: 1, limit: 20 },
});
// → GET /users?role=admin&page=1&limit=20
// Combining path params and query string
const posts = await api.get<Post[]>('/users/{id}/posts', {
params: { id: 42 },
query: { status: 'published', limit: 10 },
});
// → GET /users/42/posts?status=published&limit=10Managing Headers
// Update at runtime — settles on all subsequent requests
api.headers({ Authorization: `Bearer ${newToken}` });
// Remove a header by setting it to undefined
api.headers({ Authorization: undefined });Cancelling In-Flight Requests
cancelAll() aborts every active request without disposing the client. The client stays usable for new requests immediately after.
// Route change — drop all pending background fetches
api.cancelAll();
// Client is still alive and ready
const fresh = await api.get<Config>('/config');Request Deduplication
GET, HEAD, OPTIONS, and DELETE requests are deduplicated automatically. Writes are never deduplicated unless you pass an explicit dedupeKey.
// Only ONE network call is made
const [a, b, c] = await Promise.all([api.get('/users/1'), api.get('/users/1'), api.get('/users/1')]);
console.log(a === b); // true — same promise
// Writes only dedupe when you give them a stable key
const result = await api.post('/data', { body: payload, dedupeKey: ['save-draft', draftId] });Per-Request Options
All standard RequestInit options are supported alongside Fetchit extensions:
const data = await api.get<Data>('/protected', {
headers: { 'X-Custom': 'value' }, // per-request headers merged over globals
signal: controller.signal, // AbortSignal for cancellation
timeout: 5_000, // override client-level timeout
});Query Param Arrays
query supports scalars, arrays, and null.
await api.get('/users', {
query: { page: [1, 2], search: null, role: 'admin' },
});
// → /users?page=1&page=2&search=&role=adminInterceptors
use(interceptor) adds middleware that wraps every request. Returns a dispose function. Interceptors are called in registration order.
// Auth — inject a fresh token before each request
const removeAuth = api.use(async (ctx, next) => {
const token = await getAccessToken();
ctx.init.headers = { ...(ctx.init.headers as Record<string, string>), Authorization: `Bearer ${token}` };
return next(ctx);
});
// Logging
const removeLog = api.use(async (ctx, next) => {
const start = Date.now();
const res = await next(ctx);
console.log(`${ctx.init.method} ${ctx.url} → ${res.status} (${Date.now() - start}ms)`);
return res;
});
// Remove interceptors when no longer needed
removeAuth();
removeLog();An interceptor can short-circuit the chain by returning a Response without calling next(ctx).
Query Client
createQuery() provides cache-backed reads with request deduplication, prefix invalidation, and reactive subscriptions for any async data source.
Creating a Query Client
import { createQuery } from '@vielzeug/fetchit';
const qc = createQuery({
staleTime: 0, // default: 0 — data is immediately stale
gcTime: 300_000, // default: 5 min — GC runs at background priority once an entry is unobserved
retry: 1, // default: 1 retry attempt
retryDelay: undefined, // default: exponential backoff (1s → 2s → 4s → … up to 30s)
shouldRetry: undefined, // default: undefined — retries all errors
refetchOnFocus: false, // default: false — revalidate stale entries when tab regains focus
refetchOnReconnect: false, // default: false — revalidate stale entries on network reconnect
});query(options)
Fetches data with automatic caching, deduplication, and retry. The fn receives a QueryFnContext with both the cache key and an AbortSignal.
const user = await qc.query({
key: ['users', userId],
fn: ({ key, signal }) => api.get<User>('/users/{id}', { params: { id: key[1] as number }, signal }),
staleTime: 5_000,
retry: 3,
shouldRetry: (err) => !HttpError.is(err) || (err.status ?? 500) >= 500,
});| Option | Type | Default | Description |
|---|---|---|---|
key | QueryKey | required | Cache identifier; serialized with stable key ordering |
fn | (ctx: QueryFnContext) => Promise<T> | required | Data-fetching function; receives { key, signal } |
staleTime | number | 0 | ms served from cache before the next query() call refetches |
gcTime | number | 300000 | ms before an unobserved entry is GC'd at background priority while unobserved |
retry | number | query-client default | Retry attempts for this specific query call |
retryDelay | number | (attempt) => number | query-client default | Delay strategy for this specific query call |
shouldRetry | (error, attempt) => boolean | query-client default | Retry predicate for this specific query call |
enabled | boolean | true | Skip the fetch when false; entry stays 'idle' and existing data is returned |
initialData | T | () => T | undefined | — | Pre-seed the cache as a successful entry when no data exists |
placeholderData | T | () => T | undefined | — | Shown as data to subscribers while the entry is fetching; not stored in cache |
Per-query retry options override createQuery() defaults when provided.
Retry semantics
retry: 3 means 3 retries (4 total attempts: 1 initial + 3 retries). retry: 0 means 1 attempt only.
Conditional Fetching
Set enabled: false to skip the fetch. The entry stays 'idle' and any cached data is returned. Useful for dependent queries or fields that should not load until the user interacts.
// Only fetch posts once a userId is known
const posts = await qc.query({
key: ['users', userId, 'posts'],
fn: ({ signal }) => api.get<Post[]>('/posts', { query: { userId }, signal }),
enabled: userId != null,
});Seeding Cache Data
initialData pre-populates the cache as a successful entry before the first fetch. If data already exists the value is ignored. Subject to normal staleTime checks.
// Seed a detail entry from an already-cached list
const user = await qc.query({
key: ['users', id],
fn: ({ signal }) => api.get<User>('/users/{id}', { params: { id }, signal }),
staleTime: 30_000,
initialData: () => qc.get<User[]>(['users'])?.find((u) => u.id === id),
});placeholderData shows a temporary value to subscribers while the real fetch is in-flight. It is not written to the cache.
const user = await qc.query({
key: ['users', id],
fn: ({ signal }) => api.get<User>('/users/{id}', { params: { id }, signal }),
placeholderData: { id, name: 'Loading…' },
});Prefetch
Warms cache data ahead of use (for example, route hover or page transition preloads). It uses the same key/fn/staleness semantics as query() but resolves to void.
await qc.prefetch({
key: ['users', 1],
fn: ({ signal }) => api.get<User>('/users/{id}', { params: { id: 1 }, signal }),
staleTime: 10_000,
});
// Next query can serve from warm cache
const user = await qc.query({
key: ['users', 1],
fn: ({ signal }) => api.get<User>('/users/{id}', { params: { id: 1 }, signal }),
staleTime: 10_000,
});By default prefetch() swallows errors after updating query state to 'error'. Use throwOnError: true to rethrow.
await qc.prefetch({
key: ['config'],
fn: ({ signal }) => api.get('/config', { signal }),
throwOnError: true,
});Cache Access
// Read cached data without triggering a fetch
const cached = qc.get<User>(['users', 1]);
// Set or update cache data directly
qc.set(['users', 1], { id: 1, name: 'Alice' });
qc.set<User[]>(['users'], (old = []) => [...old, newUser]); // updater function
// Full state snapshot
const state = qc.getState<User>(['users', 1]);
// → { data, error, status, updatedAt }subscribe(key, listener, opts?)
Subscribes to live QueryState updates for a key. Fires immediately with the current state. Returns an unsubscribe function.
const unsub = qc.subscribe<User>(['users', 1], (state) => {
console.log(state.status); // 'idle' | 'pending' | 'success' | 'error'
console.log(state.data); // T | undefined
console.log(state.error); // Error | null
});
unsub(); // stop listeningPass a select function to transform data before the listener receives it. The listener is only called when the selected value, status, or error changes — redundant notifications are skipped.
// Only notified when the user's name changes, not on unrelated field updates
const unsub = qc.subscribe<User, string>(['users', 1], (state) => renderName(state.data), {
select: (user) => user?.name,
});Subscribing keeps the cache entry alive (cancels any pending GC timer). When the last subscriber leaves, 'idle' entries are removed immediately; non-idle entries start a new gcTime countdown.
invalidate(key)
For entries without active subscribers, invalidation evicts the cache entry immediately. For entries with active subscribers:
- If the entry has a stored query function (registered via
query()), it is background-revalidated: the existing data stays visible while the refetch is in flight, then transitions tosuccessorerror. - If the entry was only ever populated via
set()with no query function, it resets toidleso subscribers get a clean blank slate.
Supports prefix matching: invalidating ['users'] purges ['users', 1], ['users', 2], etc.
qc.invalidate(['users', 1]); // exact match
qc.invalidate(['users']); // all keys starting with 'users'cancel(key)
Cancels an in-flight query without removing the cache entry. State transitions to 'success' if data exists, otherwise 'idle'.
qc.cancel(['users', 1]);clear()
Clears every cache entry. Active subscribers are notified with an 'idle' state.
qc.clear(); // good to call on logoutBackground Revalidation
Enable automatic revalidation of stale observed entries when the tab regains focus or the network reconnects.
const qc = createQuery({
staleTime: 30_000,
refetchOnFocus: true, // revalidate when document becomes visible
refetchOnReconnect: true, // revalidate when navigator comes online
});Only entries that are observed (have active subscribers) and whose data has become stale are refetched. Entries in error state that still hold stale cached data are also revalidated, so the app can recover automatically from transient failures. qc.dispose() removes the event listeners.
Stable Key Serialization
Object property order doesn't matter in query keys — Fetchit sorts keys before serialization.
// These produce the same cache entry
await qc.query({
key: ['users', { page: 1, role: 'admin' }],
fn: ({ signal }) => api.get('/users', { query: { page: 1, role: 'admin' }, signal }),
});
await qc.query({
key: ['users', { role: 'admin', page: 1 }],
fn: ({ signal }) => api.get('/users', { query: { page: 1, role: 'admin' }, signal }),
});Dispose
Both createApi and createQuery return clients that implement [Symbol.dispose] for deterministic cleanup.
{
using api = createApi({ baseUrl: 'https://api.example.com' });
using qc = createQuery();
// api and qc are automatically disposed at end of block
}
// Or manually:
qc.dispose(); // cancels all in-flight requests, clears all timers
api.dispose(); // clears in-flight dedup map and interceptorsStandalone Mutation
createMutation() creates an observable, reusable mutation handle. Each call receives (input, signal). Cancellation can be done with mutation.cancel() or with a call-level AbortSignal.
The returned mutation state is a discriminated union keyed by status: idle and pending carry data: undefined, success carries the resolved data, and error carries the failure on error.
import { createMutation } from '@vielzeug/fetchit';
const createUser = createMutation(
(input: NewUser, signal: AbortSignal) => api.post<User>('/users', { body: input, signal }),
{
onSuccess: (user) => {
qc.set(['users', user.id], user);
qc.invalidate(['users']);
},
onError: (err) => toast.error(err.message),
onSettled: () => hideSpinner(),
},
);
const user = await createUser.mutate({ name: 'Alice', email: 'alice@example.com' });Lifecycle Callbacks
Callbacks are defined on the mutation, not the call site. They fire after each mutate() run.
| Callback | Signature | Called when |
|---|---|---|
onSuccess | (data: TData) => void | Promise<void> | The run succeeds |
onError | (error: Error) => void | Promise<void> | The run fails (not aborted) |
onSettled | (data, error) => void | Promise<void> | After every run regardless of outcome |
Callback errors (sync throws and async rejections) are swallowed and never affect the mutate() result.
Cancellation
createUser.mutate({ name: 'Alice', email: 'alice@example.com' });
createUser.cancel();// External cancellation signal
const controller = new AbortController();
createUser.mutate({ name: 'Alice', email: 'alice@example.com' }, { signal: controller.signal });
controller.abort();Error Handling
All non-2xx responses and network failures throw an HttpError.
import { HttpError } from '@vielzeug/fetchit';
try {
await api.get('/users/99');
} catch (err) {
if (HttpError.is(err, 404)) {
console.log('Not found');
} else if (HttpError.is(err)) {
console.log(err.status, err.method, err.url);
console.log(err.data); // parsed response body (for non-2xx JSON responses)
console.log(err.response); // raw Response object
console.log(err.cause); // original error (for network failures)
}
}HttpError.is(err, status?) is a type-safe narrowing helper. Passing a status checks for an exact match.
// Check any HTTP error
HttpError.is(err); // → true for any HttpError
// Check specific status
HttpError.is(err, 401); // → true only for 401
HttpError.is(err, 404); // → true only for 404HttpError.kind exposes a discriminated category: 'http' | 'network' | 'abort' | 'timeout'.
if (HttpError.is(err)) {
if (err.kind === 'timeout') console.log('Timed out');
if (err.kind === 'abort') console.log('Cancelled');
}HttpError.headers gives direct access to response headers without optional-chaining through err.response.
if (HttpError.is(err)) {
const retryAfter = err.headers?.get('retry-after');
const requestId = err.headers?.get('x-request-id');
}When a query errors, the QueryState transitions to 'error' with the error on state.error. Aborted queries fall back to 'idle' when no previous data exists, or back to 'success' when a stale value was already cached.
Common Patterns
Optimistic Updates
// Apply optimistic update
qc.set<User>(['users', 1], (old) => ({ ...old!, name: 'New Name' }));
const updateUser = createMutation((input: Partial<User>, signal: AbortSignal) =>
api.put<User>('/users/{id}', { params: { id: 1 }, body: input, signal }),
);
try {
await updateUser.mutate({ name: 'New Name' });
} catch {
// Roll back on failure
qc.invalidate(['users', 1]);
}Type-Safe Query Keys
const keys = {
users: {
all: () => ['users'] as const,
detail: (id: number) => ['users', id] as const,
list: (filters: { role?: string })=> ['users', 'list', filters] as const,
},
} as const;
await qc.query({ key: keys.users.detail(42), fn: ... });
qc.invalidate(keys.users.all()); // invalidates all user keysDependent Queries
const user = await qc.query({
key: ['users', userId],
fn: ({ signal }) => api.get<User>('/users/{id}', { params: { id: userId }, signal }),
});
if (user) {
await qc.query({
key: ['users', userId, 'posts'],
fn: ({ signal }) => api.get<Post[]>('/users/{id}/posts', { params: { id: userId }, signal }),
});
}Custom Retry Delay
The built-in default uses full jitter: Math.random() * Math.min(1000 * 2 ** attempt, 30_000). You can override this per query client or per individual query call.
const retryingQc = createQuery({
retry: 4,
// Deterministic capped exponential backoff — override the jittered default
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000), // 1s, 2s, 4s, 8s
shouldRetry: (err) => !HttpError.is(err) || (err.status ?? 500) >= 500, // skip 4xx
});
await retryingQc.query({
key: ['data'],
fn: ({ signal }) => api.get('/data', { signal }),
});Framework Integration
import { useState, useEffect, useCallback } from 'react';
import { createApi, createQuery, HttpError } from '@vielzeug/fetchit';
const api = createApi({ baseUrl: 'https://api.example.com' });
const qc = createQuery({ staleTime: 30_000 });
type User = { id: number; name: string };
function useUser(id: number) {
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
qc.query({ key: ['users', id], fn: ({ signal }) => api.get<User>(`/users/{id}`, { params: { id }, signal }) })
.then((data) => { if (!cancelled) setUser(data); })
.catch((err) => { if (!cancelled) setError(HttpError.is(err) ? `HTTP ${err.status}` : 'Unknown error'); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [id]);
return { user, error, loading };
}import { ref, watchEffect } from 'vue';
import { createApi, createQuery, HttpError } from '@vielzeug/fetchit';
const api = createApi({ baseUrl: 'https://api.example.com' });
const qc = createQuery({ staleTime: 30_000 });
type User = { id: number; name: string };
function useUser(id: number) {
const user = ref<User | null>(null);
const error = ref<string | null>(null);
const loading = ref(false);
watchEffect(async (onCleanup) => {
let cancelled = false;
onCleanup(() => { cancelled = true; });
loading.value = true;
try {
const data = await qc.query({ key: ['users', id], fn: ({ signal }) => api.get<User>('/users/{id}', { params: { id }, signal }) });
if (!cancelled) user.value = data;
} catch (err) {
if (!cancelled) error.value = HttpError.is(err) ? `HTTP ${err.status}` : 'Unknown error';
} finally {
if (!cancelled) loading.value = false;
}
});
return { user, error, loading };
}<script lang="ts">
import { onDestroy } from 'svelte';
import { createApi, createQuery, HttpError } from '@vielzeug/fetchit';
export let userId: number;
const api = createApi({ baseUrl: 'https://api.example.com' });
const qc = createQuery({ staleTime: 30_000 });
type User = { id: number; name: string };
let user: User | null = null;
let error: string | null = null;
let loading = false;
const controller = new AbortController();
onDestroy(() => controller.abort());
loading = true;
qc.query({ key: ['users', userId], fn: ({ signal }) => api.get<User>('/users/{id}', { params: { id: userId }, signal: AbortSignal.any([signal, controller.signal]) }) })
.then((data) => { user = data; })
.catch((err) => { error = HttpError.is(err) ? `HTTP ${err.status}` : 'Unknown error'; })
.finally(() => { loading = false; });
</script>
{#if loading}<p>Loading…</p>{:else if error}<p>Error: {error}</p>{:else if user}<p>{user.name}</p>{/if}Pitfalls
- Forgetting cleanup/dispose calls can leak listeners or stale state.
- Skipping explicit typing can hide integration issues until runtime.
- Not handling error branches makes examples harder to adapt safely.
Working with Other Vielzeug Libraries
With Validit
Validate response payloads at the API boundary before using them.
import { createApi } from '@vielzeug/fetchit';
import { v } from '@vielzeug/validit';
const api = createApi({ baseUrl: 'https://api.example.com' });
const UserSchema = v.object({ id: v.number(), name: v.string().min(1) });
async function getUser(id: number) {
const raw = await api.get<unknown>('/users/{id}', { params: { id } });
return UserSchema.parse(raw); // throws ValidationError on unexpected shape
}With Stateit
Use a Stateit store to hold the query result and drive reactive UI without framework-specific hooks.
import { createApi, createQuery } from '@vielzeug/fetchit';
import { store, effect } from '@vielzeug/stateit';
type User = { id: number; name: string };
const api = createApi({ baseUrl: 'https://api.example.com' });
const qc = createQuery({ staleTime: 30_000 });
const userStore = store<{ user: User | null; loading: boolean }>({ user: null, loading: false });
async function loadUser(id: number) {
userStore.patch({ loading: true });
const user = await qc.query({ key: ['users', id], fn: ({ signal }) => api.get<User>('/users/{id}', { params: { id }, signal }) });
userStore.patch({ user, loading: false });
}
effect(() => console.log('user:', userStore.value.user?.name));Best Practices
- Create one
createApiinstance per base URL and reuse it across the app. - Set
staleTimeoncreateQueryto match your data's freshness requirements; default is0(always refetch). - Use
qc.invalidate([prefix])to invalidate related queries after a mutation. - Always pass the
signalfrom query/mutationfnparameters to the underlying request to support cancellation. - Use
HttpError.is(err)to distinguish HTTP errors from network errors before reading.status. - Prefer
initialDataandplaceholderDatato avoid loading state flicker on navigation. - Use
createMutationfor write operations — it provides observable state and a built-incancel(). - Dispose query clients in long-lived contexts (server-side, tests) to prevent background polling leaks.