Data Fetching & Caching

Every SPA needs to fetch data from APIs. The naive approach — call fetch() inside a component, manage loading and error state manually, sprinkle in some try/catch blocks — works for small applications. But it falls apart quickly:

  • Duplicated loading/error logic. Every component that fetches data reimplements the same isLoading, isError, data pattern. Multiply this by dozens of components and you have a maintenance problem.
  • No caching. The same data gets fetched multiple times as users navigate between pages. The monitor list fetched on the dashboard is fetched again when the user navigates back from the detail page.
  • Race conditions. When parameters change faster than requests complete, old responses arrive after new ones and overwrite the current state with stale data.
  • No type safety. Response data is any by default. You either cast everywhere or build a custom type layer.
  • No invalidation strategy. When a mutation changes server state, there is no systematic way to mark related queries as stale.

WarpKit’s @warpkit/data package solves all of these problems with a config-driven data layer built for Svelte 5.

WarpKit’s Approach: Config-Driven Data Layer

Instead of writing fetch() calls scattered across components, you configure a DataClient that knows about your API’s shape. Every data endpoint is defined once in a central configuration, and components access data through type-safe hooks.

import { DataClient } from '@warpkit/data';
import { ETagCacheProvider } from '@warpkit/cache';

const dataClient = new DataClient(
  {
    baseUrl: '/api',
    timeout: 10000,
    keys: {
      'monitors': {
        key: 'monitors',
        url: '/monitors',
        staleTime: 30000,
        invalidateOn: ['monitor:created', 'monitor:deleted']
      },
      'monitors/:id': {
        key: 'monitors/:id',
        url: (params) => `/monitors/${params.id}`,
        invalidateOn: ['monitor:updated']
      },
      'projects': {
        key: 'projects',
        url: '/projects',
        staleTime: 60000
      }
    },
    onRequest: async (request) => {
      const token = await getAuthToken();
      request.headers.set('Authorization', `Bearer ${token}`);
      return request;
    }
  },
  { cache: new ETagCacheProvider() }
);

This configuration tells the DataClient:

  • The base URL for all requests
  • The URL pattern for each data key (with support for parameterized URLs)
  • How long data should be considered fresh (staleTime)
  • Which events trigger cache invalidation (invalidateOn)
  • How to modify outgoing requests (for auth headers, custom headers, etc.)
  • Which cache implementation to use

Once configured, components never think about URLs, headers, or caching again. They just ask for data by key.

Type Registry

WarpKit uses TypeScript module augmentation to provide full type inference for data keys. You declare a DataRegistry that maps key names to their data types:

declare module '@warpkit/data' {
  interface DataRegistry {
    'monitors': {
      data: Monitor[];
      mutations: {
        create: { input: CreateMonitorInput; output: Monitor };
        update: { input: UpdateMonitorInput; output: Monitor };
        remove: { input: string; output: void };
      };
    };
    'monitors/:id': {
      data: Monitor;
    };
    'projects': {
      data: Project[];
    };
  }
}

With this declaration in place, useData('monitors') returns a state object where .data is typed as Monitor[] | undefined. useData('monitors/:id') returns Monitor | undefined. The key name is the only thing connecting the component to the type — there are no manual type annotations needed at the call site.

The mutations field is optional. When present, it defines the input and output types for mutation operations that are semantically associated with this data key. More on this in the mutations section below.

DataClientProvider

Before any component can use data hooks, the DataClient must be provided to the component tree via Svelte context:

<script lang="ts">
  import { DataClientProvider } from '@warpkit/data';
  import { WarpKitProvider, RouterView } from '@upstat/warpkit';

  const { warpkit, dataClient } = $props();
</script>

<WarpKitProvider {warpkit}>
  <DataClientProvider client={dataClient}>
    <RouterView />
  </DataClientProvider>
</WarpKitProvider>

All child components can now call useData(), useQuery(), useMutation(), or getDataClient() to access the data layer.

useQuery — The Lower-Level Hook

useQuery is the foundational data fetching hook. It fetches data for a configured key and maintains reactive state:

<script lang="ts">
  import { useQuery } from '@warpkit/data';

  const monitors = useQuery({ key: 'monitors' });
</script>

{#if monitors.isLoading}
  <LoadingSkeleton />
{:else if monitors.isError}
  <ErrorMessage error={monitors.error} onRetry={monitors.refetch} />
{:else}
  {#each monitors.data ?? [] as monitor}
    <MonitorCard {monitor} />
  {/each}
{/if}

The returned object has these properties:

PropertyTypeDescription
dataT | undefinedThe fetched data. undefined while loading.
isLoadingbooleantrue while the initial fetch is in progress
isRevalidatingbooleantrue when showing stale cached data while fetching fresh data in background
isErrorbooleantrue if the fetch resulted in an error
isSuccessbooleantrue if data was fetched successfully
errorError | nullThe error object if fetch failed, null otherwise
refetch() => Promise<void>Manually trigger a refetch

CRITICAL: Never Destructure the Return Value

This is the single most important rule when using data hooks in WarpKit.

// WRONG -- breaks reactivity
const { data, isLoading } = useQuery({ key: 'monitors' });

// CORRECT -- maintains reactivity
const monitors = useQuery({ key: 'monitors' });
// Always access through the original object: monitors.data, monitors.isLoading

Svelte 5’s $state reactivity requires property access through the original object. When you destructure, you capture the current value of each property at the moment of destructuring. Those local variables are not reactive — they will never update when new data arrives or when loading state changes. Always access properties through the returned object.

Parameterized Queries

For data keys with URL parameters, pass them in the params option:

const monitor = useQuery({
  key: 'monitors/:id',
  params: { id: monitorId }
});

The DataClient resolves the URL by replacing :id in the configured URL pattern with the provided value.

Reactive Parameters

When parameters come from reactive sources (like page params or component state), use a getter function so the $effect inside useQuery tracks the dependency:

<script lang="ts">
  import { useQuery } from '@warpkit/data';
  import { usePage } from '@upstat/warpkit';

  const page = usePage();

  // Re-fetches automatically when page.params.id changes
  const monitor = useQuery({
    key: 'monitors/:id',
    params: () => ({ id: page.params.id })
  });
</script>

When the params getter returns new values, the hook automatically aborts any in-flight request and starts a new fetch with the updated parameters. Race conditions are handled internally via fetch ID tracking.

Conditional Fetching

Sometimes you do not want to fetch data until certain conditions are met. The enabled option controls this:

// Static condition -- fetch only if monitorId is truthy
const monitor = useQuery({
  key: 'monitors/:id',
  params: { id: monitorId },
  enabled: !!monitorId
});

// Reactive condition -- re-evaluated by Svelte 5's $effect
const monitor = useQuery({
  key: 'monitors/:id',
  params: () => ({ id: selectedId }),
  enabled: () => !!selectedId
});

When enabled is false, the hook sets isLoading to false and does not fetch. When enabled transitions from false to true, the fetch begins.

Polling with refetchInterval

For data that needs to stay fresh without relying on events, use refetchInterval:

const monitors = useQuery({
  key: 'monitors',
  refetchInterval: 15000 // Re-fetch every 15 seconds
});

The interval fetch bypasses the cache (invalidates before fetching) to ensure fresh data. The initial fetch still uses cache for a fast first paint. The timer is cleaned up when the component unmounts or when enabled becomes false.

Fetch Delay

For development and design work, the delay option adds a pause before each fetch. This is useful for previewing loading skeletons and transitions:

const monitors = useQuery({
  key: 'monitors',
  delay: 1000 // Wait 1 second before fetching (development only)
});

useData — Query Hook with Call-Site Config

useData is a query hook that adds call-site invalidateOn and enabled config on top of the DataClient’s key configuration. It returns the same query state as useQuery. For mutations, use useMutation (see below).

<script lang="ts">
  import { useData } from '@warpkit/data';

  const monitors = useData('monitors', {
    invalidateOn: ['monitor:created', 'monitor:deleted'],
    enabled: () => !!userId
  });
</script>

{#if monitors.isLoading}
  <LoadingSkeleton />
{:else}
  {#each monitors.data ?? [] as monitor}
    <MonitorCard {monitor} />
  {/each}
{/if}

The useData hook returns the same query state properties as useQuery (data, isLoading, isError, isSuccess, error, isRevalidating, refetch). The URL, staleTime, and other key-level config are defined in the DataClient’s key configuration — useData only accepts call-site overrides for invalidation events and the enabled flag.

useData Options

OptionTypeDescription
invalidateOnstring[]Event names that trigger a refetch
enabledboolean | (() => boolean)Whether the query is active (default: true)

useMutation — Standalone Mutations

For write operations that do not belong to a specific data key — authentication, form submissions, one-off API calls — use the standalone useMutation hook:

<script lang="ts">
  import { useMutation } from '@warpkit/data';

  const createMonitor = useMutation({
    mutationFn: async (input: { name: string; url: string }) => {
      const response = await fetch('/api/monitors', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input)
      });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return response.json();
    },
    onSuccess: (data) => {
      // Emit event to invalidate monitor lists
      warpkit.events.emit('monitor:created', { id: data.id });
    },
    onError: (error) => {
      console.error('Failed to create monitor:', error);
    }
  });
</script>

<button
  onclick={() => createMonitor.mutate({ name: 'My Monitor', url: 'https://example.com' })}
  disabled={createMonitor.isPending}
>
  {createMonitor.isPending ? 'Creating...' : 'Create Monitor'}
</button>

{#if createMonitor.isError}
  <p class="text-red-500">{createMonitor.error?.message}</p>
{/if}

useMutation Options

OptionTypeDescription
mutationFn(variables: TVariables) => Promise<TData>The async function that performs the mutation
onSuccess(data, variables) => voidCalled when mutation succeeds
onError(error, variables) => voidCalled when mutation fails
onSettled(data, error, variables) => voidCalled when mutation completes (success or error)

Mutation State

The returned object has these properties:

PropertyTypeDescription
mutate(variables) => Promise<TData>Execute the mutation
mutateAsync(variables) => Promise<TData>Alias for mutate
isPendingbooleantrue while mutation is executing
isSuccessbooleantrue if mutation succeeded
isErrorbooleantrue if mutation failed
isIdlebooleantrue if mutation has not been called yet
errorTError | nullError if mutation failed
dataTData | undefinedLast successful result
reset() => voidReset state back to idle

Note that mutate re-throws errors after calling onError. If you call await createMonitor.mutate(input), wrap it in a try/catch if you need to handle errors at the call site rather than in the onError callback.

Caching Deep Dive

Caching is one of the hardest problems in client-side data management. WarpKit provides a layered caching system that handles common scenarios out of the box while remaining fully customizable.

Cache Providers

WarpKit ships with four cache implementations:

ProviderDescriptionUse Case
NoCacheProviderNever stores or returns dataDebugging, always-fresh data
MemoryCacheIn-memory LRU cacheShort-lived data, development
StorageCachelocalStorage-backed cachePersist across page loads
ETagCacheProviderTwo-tier (Memory + Storage) with E-Tag supportProduction recommended

The NoCacheProvider is the default when you do not specify a cache. Every fetch hits the network. This is safe but slow.

For production, use ETagCacheProvider:

import { DataClient } from '@warpkit/data';
import { ETagCacheProvider } from '@warpkit/cache';

const dataClient = new DataClient(config, {
  cache: new ETagCacheProvider({
    memory: { maxEntries: 200 },
    storage: { prefix: 'myapp:cache:' }
  })
});

How the ETagCacheProvider Works

The ETagCacheProvider combines two tiers of caching:

L1: Memory Cache (fast, volatile) — An in-memory LRU cache using a Map. Lookups are synchronous and nearly instant. Limited to a configurable number of entries (default: 100). Lost on page refresh.

L2: Storage Cache (slower, persistent) — A localStorage-backed cache that survives page reloads and browser restarts. Handles quota exceeded and corrupted JSON gracefully.

The lookup order is: Memory -> Storage -> Network.

When a cache hit occurs in the storage tier but not in memory, the entry is promoted to memory for faster subsequent access. Writes use a write-through strategy: every cache update writes to both tiers simultaneously.

How E-Tag Caching Works

E-Tag caching is where the real efficiency gains come from. Here is the full flow:

First request:

  1. Component calls useQuery({ key: 'monitors' })
  2. DataClient checks cache — nothing there
  3. DataClient sends GET /api/monitors
  4. Server returns 200 OK with body and header ETag: "abc123"
  5. DataClient stores the data and ETag in the cache
  6. Component receives the data

Subsequent request (data is stale or cache was invalidated):

  1. Component calls useQuery({ key: 'monitors' })
  2. DataClient finds a stale cache entry with etag: "abc123"
  3. DataClient sends GET /api/monitors with header If-None-Match: "abc123"
  4. Server checks whether data has changed since that ETag

If data has NOT changed:

  • Server returns 304 Not Modified with no body
  • DataClient returns the cached data
  • Network bandwidth saved — only headers were transferred

If data HAS changed:

  • Server returns 200 OK with new body and new ETag: "def456"
  • DataClient updates the cache with new data and new ETag
  • Component receives the new data

This gives you the best of both worlds: freshness guarantees (you always check with the server) and bandwidth efficiency (unchanged data is not re-downloaded). For APIs returning large datasets, 304 responses can be dramatically faster than full 200 responses.

Stale Time

The staleTime option controls how long cached data is considered fresh. While data is fresh, the cache is returned immediately without any network request:

keys: {
  'projects': {
    key: 'projects',
    url: '/projects',
    staleTime: 60000  // Fresh for 60 seconds
  }
}
  • No staleTime (default): Data is always considered stale. Every fetch checks the network (with E-Tag for efficiency).
  • staleTime: 30000: Data is served from cache without a network request for 30 seconds after being fetched. After that, the next fetch goes to the network.

Choose staleTime based on how frequently your data changes. Project lists that rarely change can have a long stale time. Monitor status data that changes every few seconds should have a short stale time or no stale time at all.

Stale-While-Revalidate

By default, WarpKit uses a stale-while-revalidate strategy for all cached data. When a component mounts and stale cached data exists, WarpKit immediately shows the cached data while fetching fresh data in the background. When the fresh data arrives, it silently replaces the stale data — no loading skeleton, no flash.

This means page navigations feel instant when cached data is available, even if that data is stale. The user sees content immediately rather than waiting for the network.

How it works:

  1. Component mounts, useQuery runs
  2. WarpKit checks the cache — finds stale data from a previous visit
  3. Immediately sets data to the cached value, isLoading stays false
  4. Fetches fresh data from the network in the background
  5. When fresh data arrives, data updates silently
  6. If the network request fails, the stale data stays visible (no error flash)

You can detect background revalidation with isRevalidating:

<script lang="ts">
  const monitors = useQuery({ key: 'monitors' });
</script>

{#if monitors.isLoading}
  <LoadingSkeleton />
{:else}
  {#if monitors.isRevalidating}
    <SubtleRefreshIndicator />
  {/if}
  {#each monitors.data ?? [] as monitor}
    <MonitorCard {monitor} />
  {/each}
{/if}

Disabling SWR for specific keys:

Some data should always show a loading state rather than stale data — for example, billing information or security-sensitive data where showing outdated values could be misleading:

keys: {
  'billing/usage': {
    key: 'billing/usage',
    url: '/billing/usage',
    staleWhileRevalidate: false  // Always show loading, never stale data
  }
}

When SWR applies:

  • Initial page load with existing cache — yes
  • Parameter changes (e.g., navigating between monitor details) — yes
  • Event-driven invalidation (cache is cleared first) — no (no stale data to show)
  • Polling via refetchIntervalno (already silent)
  • Manual refetch()no (explicit user action)

Disabling Cache for Specific Keys

Some queries should never be cached — point-in-time analytics, search results with dynamic filters, or any data where staleness is unacceptable:

keys: {
  'analytics/snapshot': {
    key: 'analytics/snapshot',
    url: '/analytics/snapshot',
    cache: false  // Always hits the network
  }
}

Cache Invalidation

There are two ways to invalidate cached data: programmatic and event-driven.

Programmatic invalidation — call methods on the DataClient directly:

// Invalidate a specific key with specific params
await dataClient.invalidate('monitors/:id', { id: '123' });

// Invalidate all entries matching a prefix
// This clears 'monitors', 'monitors/:id' for ALL ids, etc.
await dataClient.invalidateByPrefix('monitors');

// Clear the entire cache
await dataClient.clearCache();

Event-driven invalidation — configure invalidateOn in your data keys:

keys: {
  'monitors': {
    key: 'monitors',
    url: '/monitors',
    invalidateOn: ['monitor:created', 'monitor:deleted', 'monitor:updated']
  }
}

When any of these events fire, the DataClient automatically clears the cache for this key. If a component is currently mounted with useQuery({ key: 'monitors' }), it will also refetch to get fresh data.

Events are emitted from wherever the mutation happens:

// After creating a monitor
warpkit.events.emit('monitor:created', { id: newMonitor.id });
// The monitors list cache is automatically cleared and refetched

This two-layer approach is important. The DataClient subscribes to invalidation events globally (not per-component), so the cache is cleared even when no component is mounted for that key. When a component does mount later, it sees the empty cache and fetches fresh data.

Cache Scoping

In multi-tenant applications, you often need to scope the cache to the current user or project. The scopeCache method creates a scoped prefix:

// After user logs in
dataClient.scopeCache(user.accountUuid);
// Cache keys become: warpkit:<accountUuid>:monitors, etc.

// After user logs out
await dataClient.clearCache();

WarpKit can automate this when you integrate the data client with the WarpKit instance:

const warpkit = createWarpKit({
  routes,
  initialState: 'unauthenticated',
  data: {
    client: dataClient,
    scopeKey: (stateData) => stateData?.cacheScope
  }
});

When the app state transitions, WarpKit automatically clears the old cache and scopes the new one using the scopeKey callback.

DataClient API Reference

Constructor

new DataClient(config: DataClientConfig, options?: DataClientOptions)

DataClientConfig:

PropertyTypeRequiredDescription
keysRecord<DataKey, DataKeyConfig>YesMap of data keys to their configurations
baseUrlstringNoBase URL prepended to all data URLs
timeoutnumberNoRequest timeout in ms (default: 30000)
onRequest(request: Request) => Request | Promise<Request>NoRequest interceptor
retryOn429booleanNoAuto-retry on HTTP 429 (default: true)
maxRetriesnumberNoMax retry attempts for 429 responses (default: 3)

DataClientOptions:

PropertyTypeDescription
cacheCacheProviderCache implementation (default: NoCacheProvider)

Event wiring: Events are not configured in the constructor. Call setEvents(events) to wire up event-driven cache invalidation. WarpKit does this automatically — it shares its own EventEmitter with the DataClient so all events (auth, data invalidation, consumer events) flow through one instance.

Methods

MethodSignatureDescription
fetchfetch<K>(key: K, params?): Promise<FetchResult>Fetch data for a configured key
mutatemutate<T>(url, options): Promise<T>Execute a mutation (POST/PUT/PATCH/DELETE)
getQueryDatagetQueryData<K>(key, params?): Promise<T | undefined>Get cached data without fetching
setQueryDatasetQueryData<K>(key, data, params?): Promise<void>Set data in cache (for optimistic updates)
invalidateinvalidate(key, params?): Promise<void>Invalidate a specific cache entry
invalidateByPrefixinvalidateByPrefix(prefix): Promise<void>Invalidate all entries matching a prefix
clearCacheclearCache(): Promise<void>Clear all cached data
scopeCachescopeCache(scope): voidScope cache to a key (requires ETagCacheProvider)
setEventssetEvents(events): voidSet event emitter and subscribe to invalidation events. WarpKit calls this automatically.
resolveUrlresolveUrl(template, params?): stringResolve a URL template with parameters

Direct DataClient Usage

While hooks are the primary API, you can use the DataClient directly for imperative operations:

import { getDataClient } from '@warpkit/data';

// Inside a component (within DataClientProvider context)
const client = getDataClient();

// Fetch data directly
const result = await client.fetch('monitors');

// Execute a mutation
const newMonitor = await client.mutate('/monitors', {
  method: 'POST',
  body: { name: 'New Monitor', url: 'https://example.com' }
});

// Optimistic update
await client.setQueryData('monitors/:id', updatedMonitor, { id: '123' });

// Read cache
const cached = await client.getQueryData('monitors');

Implementing a Custom CacheProvider

If the built-in cache providers do not meet your needs, you can implement the CacheProvider interface:

import type { CacheProvider, CacheEntry } from '@warpkit/data';

class IndexedDBCache implements CacheProvider {
  async get<T>(key: string): Promise<CacheEntry<T> | undefined> {
    // Read from IndexedDB
  }

  async set<T>(key: string, entry: CacheEntry<T>): Promise<void> {
    // Write to IndexedDB
  }

  async delete(key: string): Promise<void> {
    // Delete from IndexedDB
  }

  async deleteByPrefix(prefix: string): Promise<void> {
    // Delete all entries with keys starting with prefix
  }

  async clear(): Promise<void> {
    // Clear all entries
  }
}

const dataClient = new DataClient(config, {
  cache: new IndexedDBCache()
});

The CacheEntry type includes the data, an optional ETag, a timestamp, and an optional stale time:

interface CacheEntry<T = unknown> {
  data: T;
  etag?: string;
  timestamp: number;
  staleTime?: number;
}

Your cache implementation does not need to understand ETags or stale time — it just stores and retrieves CacheEntry objects. The DataClient handles all the freshness and E-Tag logic internally.

Error Handling

HttpError

When a request fails (non-2xx response), DataClient throws an HttpError with the status code, status text, and parsed response body:

import { HttpError } from '@warpkit/data';

try {
  await client.mutate('/monitors', { method: 'POST', body: input });
} catch (error) {
  if (error instanceof HttpError) {
    console.log(error.status);       // 422
    console.log(error.statusText);   // "Unprocessable Entity"
    console.log(error.body);         // { message: "Validation failed", errors: [...] }
    console.log(error.isRateLimited); // false
  }
}

In mutation callbacks:

const createMonitor = useMutation({
  mutationFn: (input) => client.mutate('/monitors', { method: 'POST', body: input }),
  onError: (error) => {
    if (error instanceof HttpError && error.isRateLimited) {
      toast.error('Rate limited', 'Too many requests. Please try again later.');
    } else {
      toast.error('Failed', error.message);
    }
  }
});

Rate Limit Handling (429)

DataClient automatically retries requests that receive HTTP 429 (Too Many Requests) responses. This is enabled by default.

  • Reads the Retry-After header to determine delay (supports seconds and HTTP-date formats)
  • Falls back to exponential backoff if no header: 1s, 2s, 4s (capped at 30s)
  • Retries up to 3 times by default
  • If all retries are exhausted, throws an HttpError with status: 429
  • Works for both queries (fetch()) and mutations (mutate())
// Disable 429 retry (not recommended)
const client = new DataClient({
  retryOn429: false,
  // ...
});

// Custom max retries
const client = new DataClient({
  maxRetries: 5,
  // ...
});

During retry, mutations remain in isPending state, so UI buttons bound to isPending are automatically disabled.

Global Error Handling

Handle errors globally through the onRequest interceptor:

const dataClient = new DataClient({
  baseUrl: '/api',
  keys: { /* ... */ },
  onRequest: async (request) => {
    const token = await getAuthToken();
    if (!token) {
      // Redirect to login if no token available
      warpkit.setState('unauthenticated');
      throw new Error('No auth token');
    }
    request.headers.set('Authorization', `Bearer ${token}`);
    return request;
  }
});

For 401 handling specifically, you can check for auth errors in mutation callbacks:

const updateMonitor = useMutation({
  mutationFn: async (input) => {
    const client = getDataClient();
    return client.mutate(`/monitors/${input.id}`, { method: 'PUT', body: input });
  },
  onError: (error) => {
    if (error instanceof HttpError && error.status === 401) {
      warpkit.setState('unauthenticated');
    }
  }
});

Putting It All Together

Here is a complete example showing how all the pieces fit together in a real application:

data-client.ts — Central configuration:

import { DataClient } from '@warpkit/data';
import { ETagCacheProvider } from '@warpkit/cache';
import type { Monitor, Project } from './types';

// Type registry
declare module '@warpkit/data' {
  interface DataRegistry {
    'monitors': { data: Monitor[] };
    'monitors/:id': { data: Monitor };
    'projects': { data: Project[] };
  }
}

export const dataClient = new DataClient(
  {
    baseUrl: '/api',
    timeout: 10000,
    keys: {
      'monitors': {
        key: 'monitors',
        url: '/monitors',
        staleTime: 30000,
        invalidateOn: ['monitor:created', 'monitor:deleted', 'monitor:updated']
      },
      'monitors/:id': {
        key: 'monitors/:id',
        url: (params) => `/monitors/${params.id}`,
        invalidateOn: ['monitor:updated']
      },
      'projects': {
        key: 'projects',
        url: '/projects',
        staleTime: 60000
      }
    },
    onRequest: async (request) => {
      const token = await getAuthToken();
      request.headers.set('Authorization', `Bearer ${token}`);
      return request;
    }
  },
  {
    cache: new ETagCacheProvider({
      memory: { maxEntries: 200 },
      storage: { prefix: 'myapp:' }
    })
  }
);

MonitorList.svelte — A component that fetches and displays data:

<script lang="ts">
  import { useQuery } from '@warpkit/data';
  import { useMutation } from '@warpkit/data';
  import { useWarpKit } from '@upstat/warpkit';

  const warpkit = useWarpKit();
  const monitors = useQuery({ key: 'monitors' });

  const deleteMonitor = useMutation({
    mutationFn: async (id: string) => {
      const response = await fetch(`/api/monitors/${id}`, { method: 'DELETE' });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
    },
    onSuccess: (_data, id) => {
      warpkit.events.emit('monitor:deleted', { id });
    }
  });
</script>

{#if monitors.isLoading}
  <div class="grid gap-4">
    {#each Array(3) as _}
      <div class="h-24 bg-gray-100 animate-pulse rounded-lg" />
    {/each}
  </div>
{:else if monitors.isError}
  <div class="p-4 bg-red-50 rounded-lg">
    <p class="text-red-700">{monitors.error?.message}</p>
    <button onclick={monitors.refetch} class="mt-2 text-red-600 underline">
      Try again
    </button>
  </div>
{:else}
  <div class="grid gap-4">
    {#each monitors.data ?? [] as monitor (monitor.id)}
      <div class="p-4 border rounded-lg flex justify-between items-center">
        <div>
          <h3 class="font-medium">{monitor.name}</h3>
          <p class="text-sm text-gray-500">{monitor.url}</p>
        </div>
        <button
          onclick={() => deleteMonitor.mutate(monitor.id)}
          disabled={deleteMonitor.isPending}
          class="text-red-600 hover:text-red-800"
        >
          Delete
        </button>
      </div>
    {/each}
  </div>
{/if}

MonitorDetail.svelte — A component with parameterized query:

<script lang="ts">
  import { useQuery } from '@warpkit/data';
  import { usePage } from '@upstat/warpkit';

  const page = usePage();

  const monitor = useQuery({
    key: 'monitors/:id',
    params: () => ({ id: page.params.id })
  });
</script>

{#if monitor.isLoading}
  <DetailSkeleton />
{:else if monitor.isError}
  <ErrorPanel error={monitor.error} />
{:else if monitor.data}
  <h1>{monitor.data.name}</h1>
  <p>URL: {monitor.data.url}</p>
  <p>Status: {monitor.data.status}</p>
{/if}

Compared to Other Frameworks

TanStack Query (React Query)

TanStack Query is the closest analog to WarpKit’s data layer. Both provide hooks-based data fetching with caching, invalidation, and mutation support. The key differences:

  • Config-driven keys. WarpKit defines all data keys and their URLs in a central configuration. TanStack Query defines URLs at the call site. WarpKit’s approach means the data layer knows about all your endpoints at initialization time, enabling features like global event-based invalidation.
  • E-Tag support. WarpKit’s ETagCacheProvider handles conditional requests and 304 responses out of the box. TanStack Query does not have built-in E-Tag support.
  • Type registry. WarpKit uses module augmentation for type inference. TanStack Query uses generics at each call site.
  • Svelte 5 native. WarpKit’s hooks use $state and $effect directly. TanStack Query’s Svelte adapter wraps a React-centric core.

SWR (React)

SWR popularized the stale-while-revalidate pattern. WarpKit implements the same core philosophy — show stale data instantly, revalidate in the background — as a built-in default behavior. Key differences:

  • Built-in, not the whole library. WarpKit’s SWR is one feature of a comprehensive data layer. The SWR library is focused solely on the fetching pattern.
  • On by default. WarpKit enables SWR automatically for all cached keys. SWR (the library) requires explicit configuration per hook.
  • TypeScript registry. WarpKit uses module augmentation for full type inference. SWR uses string keys with manual type annotations.
  • Integrated mutations. WarpKit’s useData combines queries and mutations. SWR focuses on fetching; mutations are your problem.
  • Event-driven invalidation. WarpKit clears cache and refetches when named events fire. SWR uses manual mutate() calls.
  • Svelte 5 native. Built on $state and $effect. No React dependency.

Apollo Client

Apollo Client is designed for GraphQL. If your API uses GraphQL, consider Apollo. If your API is REST or RPC, WarpKit is a better fit. Apollo’s normalized cache is more sophisticated but only works with GraphQL’s type system.

Fetch + $effect (DIY)

The tempting approach: write fetch() calls inside $effect() and manage state manually. This works for simple cases but breaks down at scale:

  • No race condition handling (old requests overwrite new data)
  • No caching (same data fetched on every mount)
  • No invalidation strategy (stale data after mutations)
  • No loading/error state consistency
  • No type safety
  • No request deduplication

WarpKit’s data layer solves all of these problems with a consistent, tested implementation.

Next Steps