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 withContent-Type: application/json)transform— Function to transform the parsed JSON before storinginitialData— 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
useSWRcalls 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 (defaulttrue)revalidateOnReconnect— Re-fetch when the browser comes back online (defaulttrue)refreshInterval— Poll at this interval in ms (0to disable)dedupingInterval— Suppress duplicate requests within this window (default2000ms)fallbackData— Data to use before the first fetch completesonSuccess(data, key)— Callback after a successful fetchonError(error, key)— Callback after a failed fetchsuspense— 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 dataenabled— Set tofalseto prevent automatic fetching (defaulttrue)staleTime— How long data is considered fresh in ms (default0)cacheTime— How long inactive data stays in cache (default300000ms / 5 min)refetchOnWindowFocus— Re-fetch on tab focus (defaulttrue)refetchInterval— Poll interval in ms (falseto disable)retry— Number of retry attempts on failure (default3)retryDelay(attempt)— Delay function (default: exponential backoff capped at 30 s)onSuccess(data),onError(error),onSettled(data, error)— Lifecycle callbacksselect(data)— Transform cached data before returning itplaceholderData— 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 queryqueryFn({ queryKey, pageParam, signal })— Fetcher that receives the current page paramgetNextPageParam(lastPage, allPages)— Return the next page param, orundefinedto signal the endgetPreviousPageParam(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) => booleanto 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');
}