Skip to content

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

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

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

ts
// 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/99

Query String Parameters

ts
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=10

Managing Headers

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

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

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

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

ts
await api.get('/users', {
  query: { page: [1, 2], search: null, role: 'admin' },
});
// → /users?page=1&page=2&search=&role=admin

Interceptors

use(interceptor) adds middleware that wraps every request. Returns a dispose function. Interceptors are called in registration order.

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

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

ts
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,
});
OptionTypeDefaultDescription
keyQueryKeyrequiredCache identifier; serialized with stable key ordering
fn(ctx: QueryFnContext) => Promise<T>requiredData-fetching function; receives { key, signal }
staleTimenumber0ms served from cache before the next query() call refetches
gcTimenumber300000ms before an unobserved entry is GC'd at background priority while unobserved
retrynumberquery-client defaultRetry attempts for this specific query call
retryDelaynumber | (attempt) => numberquery-client defaultDelay strategy for this specific query call
shouldRetry(error, attempt) => booleanquery-client defaultRetry predicate for this specific query call
enabledbooleantrueSkip the fetch when false; entry stays 'idle' and existing data is returned
initialDataT | () => T | undefinedPre-seed the cache as a successful entry when no data exists
placeholderDataT | () => T | undefinedShown 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.

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

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

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

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

ts
await qc.prefetch({
  key: ['config'],
  fn: ({ signal }) => api.get('/config', { signal }),
  throwOnError: true,
});

Cache Access

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

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

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

ts
// 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 to success or error.
  • If the entry was only ever populated via set() with no query function, it resets to idle so subscribers get a clean blank slate.

Supports prefix matching: invalidating ['users'] purges ['users', 1], ['users', 2], etc.

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

ts
qc.cancel(['users', 1]);

clear()

Clears every cache entry. Active subscribers are notified with an 'idle' state.

ts
qc.clear(); // good to call on logout

Background Revalidation

Enable automatic revalidation of stale observed entries when the tab regains focus or the network reconnects.

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

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

ts
{
  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 interceptors

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

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

CallbackSignatureCalled 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

ts
createUser.mutate({ name: 'Alice', email: 'alice@example.com' });
createUser.cancel();
ts
// 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.

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

ts
// 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 404

HttpError.kind exposes a discriminated category: 'http' | 'network' | 'abort' | 'timeout'.

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

ts
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

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

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

Dependent Queries

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

ts
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

tsx
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 };
}
ts
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 };
}
svelte
<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.

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

ts
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 createApi instance per base URL and reuse it across the app.
  • Set staleTime on createQuery to match your data's freshness requirements; default is 0 (always refetch).
  • Use qc.invalidate([prefix]) to invalidate related queries after a mutation.
  • Always pass the signal from query/mutation fn parameters to the underlying request to support cancellation.
  • Use HttpError.is(err) to distinguish HTTP errors from network errors before reading .status.
  • Prefer initialData and placeholderData to avoid loading state flicker on navigation.
  • Use createMutation for write operations — it provides observable state and a built-in cancel().
  • Dispose query clients in long-lived contexts (server-side, tests) to prevent background polling leaks.