Data Fetching

Fetch, cache, and synchronize server state with built-in SWR and query primitives.

What ships four data-fetching hooks that cover everything from a one-line fetch to full TanStack Query-style cache management. Every hook returns reactive signals, so your UI updates automatically when data arrives, errors occur, or background revalidation completes.

import { useFetch, useSWR, useQuery, useInfiniteQuery } from 'what-framework';

useFetch

useFetch is the simplest way to load data. It fires a request on mount, aborts it on unmount, and gives you reactive data, error, and isLoading signals.

useFetch(url, options?)

Options:

  • method — HTTP method ('GET' by default)
  • body — Request body (automatically JSON-stringified)
  • headers — Additional headers (merged with Content-Type: application/json)
  • transform — Function to transform the parsed JSON before storing
  • initialData — Value to use before the first response arrives

Returns: data(), error(), isLoading(), refetch(), mutate(newData)

import { useFetch } from 'what-framework';

function UserProfile({ userId }) {
  const { data, error, isLoading } = useFetch(
    `/api/users/${userId}`,
    { transform: (json) => json.user }
  );

  return () => {
    if (isLoading()) return <p>Loading...</p>;
    if (error())    return <p>Error: {error().message}</p>;
    return <h2>{data().name}</h2>;
  };
}

Call refetch() to re-run the request at any time (the previous in-flight request is automatically aborted). Use mutate(newData) to optimistically update the local data signal without hitting the network.

useSWR

useSWR adds a shared cache layer on top of fetching. Multiple components that read the same cache key share one set of signals, so a mutation in one component instantly updates every other component displaying that key.

useSWR(key, fetcher, options?)

The SWR Pattern

Stale-While-Revalidate returns cached (stale) data immediately so the UI never shows a blank screen, then revalidates in the background and swaps in fresh data when it arrives. This gives users instant perceived performance while keeping data up to date.

Key concepts:

  • Cache key — A string that uniquely identifies the data. All useSWR calls with the same key share a single cache entry.
  • Deduplication — If two components mount at the same time with the same key, only one network request fires (controlled by dedupingInterval).
  • Revalidation — Data is automatically re-fetched when the browser tab regains focus or the network reconnects.

Options:

  • revalidateOnFocus — Re-fetch when the tab becomes visible (default true)
  • revalidateOnReconnect — Re-fetch when the browser comes back online (default true)
  • refreshInterval — Poll at this interval in ms (0 to disable)
  • dedupingInterval — Suppress duplicate requests within this window (default 2000 ms)
  • fallbackData — Data to use before the first fetch completes
  • onSuccess(data, key) — Callback after a successful fetch
  • onError(error, key) — Callback after a failed fetch
  • suspense — Reserved for future Suspense integration

Returns: data(), error(), isLoading(), isValidating(), mutate(newData, shouldRevalidate?), revalidate()

import { useSWR } from 'what-framework';

function Dashboard() {
  const { data, isValidating } = useSWR(
    '/api/stats',
    (key, { signal }) => fetch(key, { signal }).then(r => r.json()),
    { refreshInterval: 30000 }  // poll every 30 s
  );

  return () => (
    <div>
      {isValidating() && <span class="badge">Refreshing...</span>}
      <pre>{JSON.stringify(data(), null, 2)}</pre>
    </div>
  );
}

The fetcher receives the cache key as the first argument and an options object with an AbortSignal as the second. Always forward the signal so What can cancel in-flight requests when the component unmounts or a new request starts.

Conditional Fetching

Pass a falsy key (null, undefined, or false) to skip fetching entirely. This is useful for dependent queries where you need to wait for another value first.

const userId = signal(null);

// Won't fetch until userId is set
const { data } = useSWR(
  userId() ? `/api/users/${userId()}` : null,
  fetcher
);

useQuery

useQuery offers the most control. It mirrors the TanStack Query API with stale times, automatic retries with exponential backoff, and fine-grained status tracking.

useQuery(options)

Options:

  • queryKey — String or array that uniquely identifies the query (arrays are joined with :)
  • queryFn({ queryKey, signal }) — The async function that fetches data
  • enabled — Set to false to prevent automatic fetching (default true)
  • staleTime — How long data is considered fresh in ms (default 0)
  • cacheTime — How long inactive data stays in cache (default 300000 ms / 5 min)
  • refetchOnWindowFocus — Re-fetch on tab focus (default true)
  • refetchInterval — Poll interval in ms (false to disable)
  • retry — Number of retry attempts on failure (default 3)
  • retryDelay(attempt) — Delay function (default: exponential backoff capped at 30 s)
  • onSuccess(data), onError(error), onSettled(data, error) — Lifecycle callbacks
  • select(data) — Transform cached data before returning it
  • placeholderData — Synchronous placeholder until real data loads

Returns: data(), error(), status(), fetchStatus(), isLoading(), isError(), isSuccess(), isFetching(), refetch()

import { useQuery } from 'what-framework';

function RepoList({ org }) {
  const repos = useQuery({
    queryKey: ['repos', org],
    queryFn: async ({ signal }) => {
      const res = await fetch(`/api/orgs/${org}/repos`, { signal });
      if (!res.ok) throw new Error(res.statusText);
      return res.json();
    },
    staleTime: 60000,        // fresh for 1 minute
    retry: 2,                // retry twice on failure
    select: (data) => data.filter(r => !r.archived),
  });

  return () => {
    if (repos.isLoading()) return <p>Loading repos...</p>;
    if (repos.isError())   return <p>Failed: {repos.error().message}</p>;
    return (
      <ul>
        {repos.data().map(r => <li key={r.id}>{r.name}</li>)}
      </ul>
    );
  };
}

Status vs. FetchStatus

status() tells you about the data'loading' (no data yet), 'error', or 'success'. fetchStatus() tells you about the network'fetching' or 'idle'. A query can be status: 'success' and fetchStatus: 'fetching' at the same time during a background revalidation.

useInfiniteQuery

useInfiniteQuery manages paginated or infinite-scroll data. Each "page" is fetched sequentially, and the hook tracks whether more pages are available in either direction.

useInfiniteQuery(options)

Options (in addition to base query options):

  • queryKey — String or array identifying the query
  • queryFn({ queryKey, pageParam, signal }) — Fetcher that receives the current page param
  • getNextPageParam(lastPage, allPages) — Return the next page param, or undefined to signal the end
  • getPreviousPageParam(firstPage, allPages) — Return the previous page param (optional)
  • initialPageParam — The param for the first page

Returns: data() (object with pages and pageParams arrays), hasNextPage(), hasPreviousPage(), fetchNextPage(), fetchPreviousPage(), refetch()

import { useInfiniteQuery } from 'what-framework';

function Feed() {
  const feed = useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: async ({ pageParam, signal }) => {
      const res = await fetch(`/api/feed?cursor=${pageParam}`, { signal });
      return res.json();
    },
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });

  return () => (
    <div>
      {feed.data().pages.map(page =>
        page.items.map(item => <Card key={item.id} item={item} />)
      )}
      {feed.hasNextPage() && (
        <button onClick={() => feed.fetchNextPage()}>
          Load more
        </button>
      )}
    </div>
  );
}

Refetch Behavior

When you call refetch() on an infinite query, the existing pages remain visible while the first page is re-fetched in the background (SWR pattern). Once the fresh first page arrives, all pages are replaced atomically. Page fetches are always sequential to preserve ordering.

Cache Management

What provides standalone functions to read, write, and invalidate the shared query cache from anywhere in your application.

import {
  invalidateQueries,
  prefetchQuery,
  setQueryData,
  getQueryData,
  clearCache,
} from 'what-framework';

invalidateQueries(keyOrPredicate, options?)

Marks one or more cache entries as stale and triggers all active subscribers to re-fetch.

  • Pass a string to invalidate a single key.
  • Pass a function (key) => boolean to invalidate all matching keys.
  • Set { hard: true } to clear the cached data immediately (shows a loading state). The default soft invalidation keeps stale data visible while re-fetching.
// Soft invalidation — keeps stale data visible
invalidateQueries('/api/stats');

// Hard invalidation — clears data, shows loading state
invalidateQueries('/api/stats', { hard: true });

// Invalidate all keys matching a predicate
invalidateQueries(key => key.startsWith('/api/users'));

prefetchQuery(key, fetcher)

Pre-fills the cache before a component mounts. Useful for route prefetching on hover.

// Prefetch on link hover
<a
  href="/dashboard"
  onMouseEnter={() => prefetchQuery(
    '/api/stats',
    (key) => fetch(key).then(r => r.json())
  )}
>Dashboard</a>

setQueryData(key, updater)

Directly write to the cache. The updater can be a new value or a function that receives the current cached value. All active subscribers for that key update immediately.

// Optimistic update after a mutation
setQueryData('/api/todos', (old) =>
  [...old, { id: Date.now(), text: 'New todo', done: false }]
);

getQueryData(key)

Synchronously read the current cached value for a key. Returns undefined if the key has never been fetched.

const cached = getQueryData('/api/user');
if (cached) console.log(cached.name);

clearCache()

Removes every entry from the cache. Useful during logout or testing.

function logout() {
  clearCache();
  navigate('/login');
}
Live Demo — Data Fetching