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';
These four hooks are client-side
None of them contributes anything to server-rendered HTML. During a server render the request does go out, but it cannot resolve inside the synchronous render, so the markup ships with the empty state, and whatever useSWR or useQuery resolves afterwards is written into a module-level cache shared by every request in that process. Polling is worse: on the server there is no unmount to clear the timer, so a component rendered with refreshInterval or refetchInterval keeps calling its fetcher after the response has been sent. For data that has to be in the HTML, use a page loader and keep these hooks for what loads after hydration.
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 or an array that uniquely identifies the data. All
useSWRcalls with the same key share a single cache entry, and an array key is normalized into the same key spaceuseQueryuses:useSWR(['/api/user', 7], ...)anduseQuery({ queryKey: ['/api/user', 7] })are one entry,getQueryData(['/api/user', 7])finds it,invalidateQueriesreaches it, and two components passing equal-but-distinct arrays share it rather than quietly getting two. The fetcher still receives the original key, souseSWR(['/api/user', id], ([url, id]) => ...)is unaffected. - 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: accepted and ignored. It is read out of the options object and never used, so it changes nothing today.
Returns: data(), error(), isLoading(), isValidating(), mutate(newData, shouldRevalidate?), revalidate(options?)
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.
revalidate() is answered from the freshness window: inside dedupingInterval it resolves from the cache without touching the network. revalidate({ force: true }) is the unconditional form. mutate(newData) revalidates by the same rule, so an optimistic write made within 2 seconds of the last fetch is not confirmed against the server unless you force it.
Conditional Fetching
Pass a falsy key (null, undefined, or false) to skip fetching entirely. The hook returns an idle result (data() is fallbackData or null, isLoading() is false) and the fetcher is never called.
The key is read once, when the component runs. Since components run once, a key computed from a signal is frozen at whatever that signal held at creation time and never becomes truthy later. For a dependent query, create the component only once the value it depends on exists. This is not a useSWR quirk: useQuery reads its queryKey exactly once too, so the same shape is the answer in both hooks. useQuery's enabled option is read reactively, but it gates fetching and never moves the key.
const userId = signal(null);
function UserCard({ id }) {
const { data } = useSWR(`/api/users/${id}`, fetcher);
return () => <h2>{data() ? data().name : 'Loading...'}</h2>;
}
function App() {
// The thunk re-runs when userId changes, so UserCard is created
// with a real id and useSWR gets a key it can fetch.
return <div>{() => userId() ? <UserCard id={userId()} /> : <p>Pick a user</p>}</div>;
}
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. It is read exactly once, when the hook is created, and never again, so every value that appears in it has to be known by then. Array segments are JSON-stringified when they are not strings, and any:or\inside a segment is escaped before the segments are joined with:, so['user', 'a:b']cannot collide with['user', 'a', 'b']. The predicate form ofinvalidateQueriesreceives this normalized string, not the array.queryFn({ queryKey, signal }): The async function that fetches dataenabled: gates automatic fetching (defaulttrue). It accepts a boolean, a signal, or a thunk, and is read reactively, so a query that starts disabled begins fetching the moment the flag turns true. Only automatic fetching is gated (the mount fetch, focus, polling, invalidation); an explicitrefetch()runs regardless. A disabled query with nothing cached reportsstatus() === 'idle', soisLoading()isfalseandisIdle()istrue. A thunk may read as many signals as it likes: the query restarts only when the gate's value actually flips, not every time something inside the thunk moves.enabledis the live half of a query andqueryKeyis the frozen half, which is why a gate cannot express a dependency that also appears in the key.staleTime: how long this hook treats its data as fresh, in ms (default0). Freshness is tracked per hook instance, not per cache key, so a second component mounting on the same key inside the window still issues its own request.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: maximum number of attempts including the first (default3, which is one call plus two retries).retry: 1orretry: 0means a single attempt with no retry.retryDelay(attempt): delay function (default: exponential backoff capped at 30 s). The first retry calls it withattempt = 1, so the default waits 2 s before it.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(), isIdle(), isFetching(), isEnabled(), refetch()
refetch() takes no arguments and always goes to the network. It is not answered from the staleTime freshness window and it is not gated by enabled, so it is the supported way to run a query on demand. It returns a promise that resolves with the fetched data, or rejects with the query function's error once the retries are exhausted. A refetch in flight is not cancelled by unrelated re-renders, nor by the query being disabled underneath it; only a newer fetch of the same query, the component unmounting, or a clearCache() cancels it, and in those three cases its promise resolves with undefined. So const report = await query.refetch() has to tolerate undefined anywhere a logout can race it.
That makes "disabled query, fetch on a button click" a first-class shape: keep enabled: false, branch on isIdle() for the not-yet-run state, and call refetch() from the handler.
import { useQuery } from 'what-framework';
function Report() {
const report = useQuery({
queryKey: ['report', 'monthly'],
queryFn: async ({ signal }) => {
const res = await fetch('/api/report', { signal });
return res.json();
},
enabled: false, // nothing runs until it is asked for
});
return () => (
<div>
<button onClick={() => report.refetch()}>Run report</button>
{() => {
if (report.isIdle()) return <p>Not run yet</p>;
if (report.isLoading()) return <p>Running...</p>;
if (report.isError()) return <p>Failed: {report.error().message}</p>;
return <p>Total: {report.data().total}</p>;
}}
</div>
);
}
That shape survives a clearCache(): the query goes back to isIdle(), the panel returns to "Not run yet", and the button runs it again. The one thing to remember is that a clear cancels the request it catches, so if you read the result as const data = await report.refetch() rather than off report.data(), that data can be undefined.
Dependent Queries
A dependent query is one whose key contains something it has to wait for. Because queryKey is read once and enabled only gates fetching, the gate cannot express that dependency on its own.
A pending value in the key freezes there
Components run once, so an array literal like ['posts', user.data()?.id] is fully evaluated before useQuery ever sees it, and there is nothing left in it to observe. Writing queryKey: ['posts', user.data()?.id] alongside enabled: () => user.data() != null does gate correctly and the query does fetch, but it stores what it fetched under ['posts', undefined]: getQueryData(['posts', 1]) returns undefined and invalidateQueries(['posts', 1]) never reaches it. Worse, every instance created while the dependency is still pending freezes to that same key, so two of them are one cache entry: one panel displays the other user's rows before it has fetched anything, and the first panel is then silently overwritten with the second user's data. This is a known limitation of queryKey, not something the gate can be made to cover.
Create the query only once its key is known instead, by rendering the dependent component conditionally with the id as a prop. No enabled is needed, because the query does not exist until its key is real:
function Posts({ userId }) {
const posts = useQuery({
queryKey: ['posts', userId], // already correct the one time it is read
queryFn: () => fetchPosts(userId),
});
return <ul>{() => (posts.data() ?? []).map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
function Profile() {
const user = useQuery({ queryKey: ['user'], queryFn: fetchUser });
// The thunk re-runs when the user arrives, and <Posts> is created then.
return <div>{() => user.data() ? <Posts userId={user.data().id} /> : <Spinner />}</div>;
}
Each user then gets their own entry: getQueryData(['posts', 1]) returns that user's rows, invalidateQueries(['posts', 1]) reaches that query, and user 1's entry keeps user 1's data when user 2 arrives.
enabled remains the right option whenever the key does not depend on the pending value: a feature flag, a tab or route being active, or the "disabled query, fetch it on a button click" form above. Same rule either way, and it is worth stating plainly: the gate is live, the key is captured once, so any value that appears in the key must be known before the hook is created.
Most queries need none of this, and read like this:
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, // 2 attempts total (1 retry)
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: 'idle' (nothing fetched and nothing on its way, which is what a disabled query reports), 'loading' (no data yet, request in flight), '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. It fetches one page at a time and tracks whether more pages are available in either direction.
useInfiniteQuery(options)
Options:
queryKey: string or array identifying the query. It is handed toqueryFnand it is whatinvalidateQueriesmatches on. The pages themselves live in local signals rather than in the shared query cache.queryFn({ 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 pageenabled: the same gate asuseQuery, boolean, signal or thunk. While it is false nothing is fetched automatically andstatus()is'idle'; when it turns true the list loads from page one.refetch(),fetchNextPage()andfetchPreviousPage()are explicit calls and run either way. On an empty page list all three fetch the first page frominitialPageParam, andgetNextPageParam/getPreviousPageParamare not consulted at all: the next page of nothing is page one, and there is no last page to derive a param from. So any of the three can load a list created withenabled: false, andfetchNextPage()can retry a first page whose fetch failed.select(collection): transforms the whole{ pages, pageParams }object, not one page, anddata()returns whatever it returns. Useful for flattening:select: (all) => all.pages.flatMap(p => p.rows).retry/retryDelay(attempt): per page, with the same accounting asuseQuery.onSuccess(page),onError(error),onSettled(page, error): fired once per page fetch, not once per list.
staleTime, cacheTime, placeholderData, refetchOnWindowFocus and refetchInterval are the options this hook does not read, because each of them describes an entry in the shared cache and an infinite query does not have one. Passing them changes nothing.
Returns: data() (object with pages and pageParams arrays, unless select reshapes it), error(), status(), isLoading(), isError(), isSuccess(), isIdle(), isFetching(), isEnabled(), hasNextPage(), hasPreviousPage(), isFetchingNextPage(), isFetchingPreviousPage(), fetchNextPage(), fetchPreviousPage(), refetch(). pageParams[i] names pages[i]: one page in, it reads [0].
status() covers the whole list: 'loading' only while the first page is on its way, then 'success' or 'error', and 'idle' while the query is disabled. Paging through an existing list keeps status() at 'success' and reports itself through isFetchingNextPage(), so a list does not blink back to a spinner every time it grows. A failed fetchNextPage() also rejects the promise it returns and leaves the existing pages in place, so attach a handler to it. On a list that already has pages, fetchNextPage() asks getNextPageParam what comes next and does nothing at all when it answers undefined; reaching the end of a list never reloads page one.
invalidateQueries reaches an infinite query, by string key, by array prefix, or through a predicate, and refetches it from page one. setQueryData and getQueryData do not: they only ever touch the shared cache, so a useQuery and a useInfiniteQuery that name the same key do not share data.
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().catch(() => {})}
disabled={feed.isFetchingNextPage()}
>
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 it replaces the whole list, so the query is back to a single page. An invalidateQueries that matches the key does the same thing, and so does the moment a disabled query becomes enabled. Page fetches are not queued: starting one aborts any request still in flight, and two fetchNextPage() calls made before the first resolves read the same last page and request the same param twice. Disable the control while isFetchingNextPage() is true, as the sample above does.
Cache Management
What provides standalone functions to read, write, and invalidate the shared query cache from anywhere in your application. There is one entry per key, shared by every hook that names it, and the fetcher is not part of the key: two components reading the same key with different fetchers overwrite each other's data, last write wins. Keep a key unique per pair of data and fetcher.
import {
invalidateQueries,
prefetchQuery,
setQueryData,
getQueryData,
clearCache,
} from 'what-framework';
invalidateQueries(keyOrPredicate, options?)
Tells every mounted subscriber of a key to fetch again now. It is a "re-fetch immediately" signal rather than a flag stored on the entry: it deliberately bypasses staleTime and dedupingInterval. Request coalescing is a useSWR feature only, so waking three useSWR hooks on one key sends one request, while waking three useQuery hooks on one key sends three. A key with nothing mounted on it is not re-fetched, and a soft invalidation leaves its cached value readable. A query whose enabled gate is currently closed is not woken either: an invalidation is automatic fetching, so call refetch() when you mean "even if it is disabled".
- Pass a string to invalidate that one key.
- Pass an array to invalidate a prefix:
['todos']also invalidates['todos', 1]. Matching is on segment boundaries, so['todo']never matchestodos. - Add
{ exact: true }to treat an array as one exact key instead of a prefix. - Pass a function
(key) => booleanto invalidate all matching keys. Every array key arrives normalized, so it is a string whichever hook registered it andkey => key.startsWith('/api/posts')is safe. A key that is neither a string nor an array (a number, an object) is passed through unchanged, so guard for that if you use one. - 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 });
// Prefix: also invalidates ['todos', 1] and ['todos', 'archived']
invalidateQueries(['todos']);
// The 'todos' entry and nothing under it
invalidateQueries(['todos'], { exact: 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.
Its fetcher contract differs from useSWR's: it is called with the key only, with no options object and no AbortSignal, so the request cannot be cancelled and a fetcher written as (key, { signal }) => ... throws when passed here. An array key also arrives normalized, as the joined string '/api/posts:7' rather than the array, where useSWR hands its fetcher the original; the two still land on the same cache entry, so only the fetcher body has to know. The component that later mounts on the key paints the prefetched value immediately and still revalidates in the background.
// 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, and also for a key clearCache() has emptied.
const cached = getQueryData('/api/user');
if (cached) console.log(cached.name);
clearCache()
Empties the cache. It is safe to call with components on screen, which is what makes it the right call on logout: a key something is still reading is reset in place rather than dropped, so the clear is immediately visible (data() reads undefined, or your placeholderData if you set one, and error() reads null), and later writes to that key still reach the components that were already mounted. Keys nothing is reading are dropped outright, so getQueryData reports every cleared key as absent either way. A useInfiniteQuery is emptied too: its data() goes back to { pages: [], pageParams: [] }.
Status goes with the data. Every emptied query reads status() === 'idle', data() === undefined, isSuccess() === false and isLoading() === false, and the value it was displaying leaves the DOM. That holds whether the query was created enabled or created with enabled: false and loaded by refetch(), so the canonical guarded render lands on its idle arm instead of dereferencing a value that is gone. The same is true when any other writer empties a shared entry, for instance a sibling calling setQueryData(key, null).
The request already on the wire is cancelled as well, for all three hooks, so a response that would have arrived just after the clear is discarded instead of writing the previous user's data back onto the screen. That includes a request an explicit refetch() started: it is cancelled too, and its promise resolves with undefined. Anywhere a logout can race one, write await so it tolerates that.
// On logout, with the app still on screen
async function logout() {
await fetch('/api/logout', { method: 'POST' });
clearCache(); // every mounted query empties on screen
}
// Between tests
afterEach(() => clearCache());