Caching

How What Framework caches server data, deduplicates requests, and keeps your UI fresh.

Stale-While-Revalidate

What's data fetching is built on the SWR pattern: show cached (stale) data immediately, then revalidate in the background and update when fresh data arrives. This means your UI never shows a loading spinner for data it's already fetched.

Everything on this page is the in-browser query cache: it lives in the tab, it is keyed by query key, and it disappears on reload. The server-side render cache is a separate system with its own vocabulary (revalidatePath, revalidateTag, CDN headers) and is documented in Caching & ISR.

import { useSWR } from 'what-framework';

function UserProfile({ userId }) {
  const { data, error, isLoading, isValidating } = useSWR(
    `/api/users/${userId}`,
    (url) => fetch(url).then(r => r.json())
  );

  // First load: isLoading=true, data=null
  // After fetch: isLoading=false, data={...}
  // On revisit: data shows instantly (cached), isValidating=true in background
  // When fresh data arrives: data updates seamlessly

  return (
    <div>
      {() => {
        if (isLoading()) return <p>Loading...</p>;
        if (error()) return <p>Error: {error()?.message}</p>;
        return <h1>{() => data()?.name}</h1>;
      }}
      {() => isValidating() ? <span class="badge">Refreshing...</span> : null}
    </div>
  );
}

Signal Getters

data(), error(), isLoading(), and isValidating() are all signal getters, call them with parentheses to read the current value.

Branch inside a thunk, never with an early return

Components run once, so if (isLoading()) return <p>Loading...</p>; in the component body is evaluated a single time, while the first fetch is still in flight. The component is then pinned on "Loading..." forever. Put the branch inside a {() => ...} thunk (as above) or a <Show>, so it re-runs when the signals change. The same applies to every useSWR result on this page.

Shared Cache

The cache is global and keyed by the first argument to useSWR. Multiple components reading the same key share one cache entry and one network request. An array key works as well as a string one and is normalized into the same key space useQuery uses, so two components passing equal-but-distinct arrays share the entry, and getQueryData, setQueryData and invalidateQueries all reach it:

// Both components read from the same cache entry
function Header() {
  const { data } = useSWR('/api/user', fetchUser);
  return <span>Hi, {() => data()?.name}</span>;
}

function Sidebar() {
  const { data } = useSWR('/api/user', fetchUser);
  return <img src={() => data()?.avatar} />;
}

// Only ONE fetch request is made, and both components update together

Request Deduplication

If multiple components mount at the same time and request the same key, What deduplicates the requests. Only one fetch runs; all callers share the result. The deduplication window defaults to 2 seconds:

const { data } = useSWR('/api/posts', fetcher, {
  dedupingInterval: 5000,  // 5s dedup window (default: 2000ms)
});

The window covers two cases. While a request is in flight, another useSWR call on the same key joins that promise instead of starting a second request. Once it has completed, a further revalidation inside the window does not go to the network at all: it resolves straight from the cached value. That second case is what quietly swallows a focus revalidation or a mutate()-triggered re-fetch that lands within 2 seconds of the last one. revalidate({ force: true }) is the unconditional form, and invalidateQueries forces by the same rule.

Automatic Revalidation

What automatically revalidates cached data in several scenarios:

const { data } = useSWR('/api/posts', fetcher, {
  revalidateOnFocus: true,       // Re-fetch when tab regains focus (default)
  revalidateOnReconnect: true,   // Re-fetch when network reconnects (default)
  refreshInterval: 30000,       // Poll every 30s (0 = disabled, default)
  dedupingInterval: 2000,       // Min time between fetches (default)
});
  • Focus revalidation: when the user switches back to your tab, active queries re-fetch, and stale data stays on screen while they do. It is an ordinary revalidation and not a forced one, so the same freshness rules apply as anywhere else: a useSWR focus re-fetch that falls inside dedupingInterval (2 s by default) is swallowed, a useQuery one that falls inside its staleTime is swallowed, and a useQuery whose enabled gate is closed is not woken at all. With the default staleTime: 0, a useQuery does re-fetch on every focus.
  • Reconnect revalidation: when the browser comes back online after losing connection, data is refreshed.
  • Polling: set refreshInterval for data that changes frequently (dashboards, feeds, stock prices).

Cache Invalidation

After a mutation (create, update, delete), invalidate related queries to trigger a re-fetch:

import { invalidateQueries } from 'what-framework';

async function createPost(data) {
  await fetch('/api/posts', {
    method: 'POST',
    body: JSON.stringify(data),
  });

  // Soft invalidation (default): keeps stale data visible, re-fetches in background
  invalidateQueries('/api/posts');
}

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

// An array key matches as a PREFIX, so this also invalidates
// ['posts', 1] and ['posts', 'archived']
invalidateQueries(['posts']);

// That one key and nothing under it
invalidateQueries(['posts'], { exact: true });

// Invalidate multiple keys with a predicate. Every array key arrives
// normalized, so ['posts', 1] reads as 'posts:1'. A key that is neither
// a string nor an array is passed through as-is, so guard for that.
invalidateQueries(key => key.startsWith('/api/posts'));

Prefix matching is on segment boundaries, so ['post'] never matches 'posts'. Invalidation is a "fetch again now" signal aimed at whatever is mounted on the key: it deliberately bypasses staleTime and the dedup window, and it leaves a useQuery whose enabled gate is closed alone. Nothing re-fetches for a key no component is reading, though { hard: true } still clears that key's stored value. A useInfiniteQuery is reached too, and refetches from its first page.

Soft vs Hard

Soft invalidation (default) keeps stale data on screen while re-fetching (the SWR pattern). Hard invalidation clears the cache entry immediately, so isLoading() becomes true until the fresh response arrives. What data() reads meanwhile differs by hook: useSWR reads null, useQuery reads undefined, because its accessor is data() ?? placeholderData and the cleared null falls through to the placeholder you did not set. Branch on falsiness rather than on === null.

Manual Cache Control

Read and write cache entries directly for advanced scenarios:

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

// Read from cache without triggering a fetch
const cached = getQueryData('/api/user');

// Write to cache directly (updates all subscribers)
setQueryData('/api/user', { name: 'Alice', role: 'admin' });

// Update cache based on current value. It arrives null for a key nothing
// has fetched yet and after a hard invalidation, and undefined for a key
// clearCache() emptied while a component was still reading it. Guard for
// both: which one you get depends on state you cannot see from here.
setQueryData('/api/posts', posts =>
  (posts ?? []).map(p => p.id === 42 ? { ...p, title: 'Updated' } : p)
);

// Clear entire cache (useful for logout)
clearCache();

Optimistic Updates

Update the UI immediately before the server responds. The mutate function from useSWR lets you set local data that will be replaced when revalidation completes:

function TodoList() {
  const { data, mutate, revalidate } = useSWR('/api/todos', fetcher);

  async function toggleTodo(id) {
    // 1. Optimistically update the cache. The second argument is
    //    shouldRevalidate and it defaults to TRUE: leave it out and
    //    mutate immediately re-fetches the server that has not been
    //    updated yet, overwriting your optimistic value with the old one.
    mutate(
      todos => todos.map(t => t.id === id ? { ...t, done: !t.done } : t),
      false
    );

    // 2. Send to server
    await fetch(`/api/todos/${id}/toggle`, { method: 'POST' });

    // 3. Revalidate to get server truth
    revalidate();
  }

  return (
    <ul>
      {() => data()?.map(todo =>
        <li key={todo.id} onClick={() => toggleTodo(todo.id)}>
          {todo.done ? '✓' : '○'} {todo.text}
        </li>
      )}
    </ul>
  );
}

Why the false matters

With the default 2 second dedupingInterval, the revalidation mutate fires is usually swallowed as a duplicate, so the recipe looks fine while you are clicking quickly. On a slower interaction the re-fetch goes out, lands with the pre-mutation data, and the UI silently reverts.

Prefetching

Pre-populate the cache before a component mounts. Useful for hover-to-prefetch patterns:

import { prefetchQuery } from 'what-framework';

function PostLink({ id, title }) {
  return (
    <a
      href={`/posts/${id}`}
      onMouseenter={() => prefetchQuery(
        `/api/posts/${id}`,
        (url) => fetch(url).then(r => r.json())
      )}
    >
      {title}
    </a>
  );
}

When the user hovers, the data is fetched and cached. If they click through, useSWR returns the cached data instantly, with no loading spinner.

Conditional Fetching

Pass null, undefined, or false as the key to pause fetching. The key is read once, when the component is created, so it gates on something you already have (a prop, a route param) and stays paused for the life of that component.

That makes a ternary key the wrong tool for a dependent query: user.data() ? ... : null is evaluated before the parent fetch has resolved, so the second query takes the paused branch and never fires. Create the dependent query inside a thunk instead, once the parent data has arrived:

function UserPosts({ userId }) {
  const user = useSWR(
    `/api/users/${userId}`,
    fetcher
  );

  return (
    <div>
      <h1>{() => user.data()?.name}</h1>
      {() => user.data() ? <Posts userId={user.data().id} /> : null}
    </div>
  );
}

// <Posts> is created only when the user query resolves, so its own
// key is already correct the one time it is read.
function Posts({ userId }) {
  const posts = useSWR(`/api/users/${userId}/posts`, fetcher);
  return <div>{() => posts.data()?.map(p => <p key={p.id}>{p.title}</p>)}</div>;
}

That shape is not a useSWR quirk, and useQuery does not get to skip it. useQuery reads its queryKey exactly once at creation too, so the same extra component is the answer there: render <Posts userId={user.data().id} /> only once the id exists, and give it queryKey: ['posts', userId]. No enabled is needed, because the query does not exist until its key is real.

Do not reach for enabled here

useQuery's enabled option is read reactively, but it gates fetching and nothing else. enabled: () => user.data() != null written next to queryKey: ['posts', user.data()?.id] gates correctly and fetches, then stores the result under a key frozen at ['posts', undefined], where getQueryData(['posts', 1]) cannot find it and invalidateQueries(['posts', 1]) cannot reach it. Every instance created while the dependency is still pending freezes to that same key, so two panels become one cache entry and overwrite each other with the wrong user's rows. See Dependent Queries for the worked version.

Cache Size & Eviction

The global cache holds up to 200 entries. When the limit is exceeded, the oldest 20% of entries are evicted (LRU policy). Entries with active subscribers are never evicted.

To customize, clear stale data on logout or route transitions:

import { clearCache } from 'what-framework';
import { navigate } from 'what-framework/router';

function logout() {
  clearCache();         // Remove all cached data
  navigate('/login');  // Redirect to login
}

clearCache() is safe to call with the app still on screen, which is the point of calling it on logout. A key a mounted component is still reading is emptied in place, so the previous user's data disappears from the UI immediately and later writes to that key still reach that component; keys nothing is reading are dropped. Every emptied query settles to status() === 'idle' with data() === undefined, whether it was created enabled or created with enabled: false and loaded by refetch(), so a guarded render lands on its idle arm rather than dereferencing a value that is gone. Infinite queries are emptied too, and a request already in flight is cancelled for all three hooks, so nothing can land afterwards and repopulate them. See Data Fetching for what a refetch() caught by the clear resolves with.

Fallback & Placeholder Data

Provide initial data to show before the first fetch completes:

const { data } = useSWR('/api/settings', fetcher, {
  fallbackData: { theme: 'light', lang: 'en' },
});

// data() returns fallbackData immediately, then updates with server data

Error Handling

Errors are captured and available via the error() signal. Use the onError and onSuccess callbacks for side effects:

import { useSWR } from 'what-framework';

function DataView() {
  const { data, error, revalidate } = useSWR('/api/data', fetcher, {
    onError: (err, key) => {
      console.error(`Failed to fetch ${key}:`, err);
    },
    onSuccess: (result, key) => {
      console.log(`Fetched ${key}:`, result);
    },
  });

  // Retry on error. The branch lives inside a thunk for the same reason as
  // the first example: a bare `if (error())` in the body runs once, before
  // the fetch has had a chance to fail.
  return (
    <div>
      {() => error()
        ? <div>
            <p>Error: {error()?.message}</p>
            <button onClick={() => revalidate()}>Retry</button>
          </div>
        : <article>{() => data()?.title}</article>}
    </div>
  );
}