StunkStunk
Async

Async Chunk

Handle async operations with built-in loading, error, and data states.

asyncChunk() wraps an async fetcher function and gives you reactive loading, error, data, and lastFetched states out of the box — no manual state juggling needed.

import { asyncChunk } from "stunk/query";

const userChunk = asyncChunk(async () => {
  const res = await fetch("/api/user");
  return res.json();
});

Fetchers with no parameters are called automatically on creation. Fetchers that take parameters wait for setParams() or reload().


State shape

PropertyTypeDescription
dataT | nullThe resolved value, or null before first fetch
loadingbooleantrue while a fetch is in progress
errorE | nullThe error if the last fetch failed, otherwise null
lastFetchednumber | undefinedTimestamp of the last successful fetch
isPlaceholderDatabooleantrue when showing previous data while a new fetch is in progress

Options

| Option | Type | Default | Description | | ---------------------- | ---------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------- | --- | | key | string | auto | Deduplication key — concurrent calls with matching params share one request | | enabled | boolean \| ((params: Partial<P>) => boolean) | true | Disable fetching until ready — receives current params | | initialData | T | null | Seed data before the first fetch | | keepPreviousData | boolean | false | Show previous data while refetching — no UI flicker | | clearOnParamChange | boolean | false | Clear data to null immediately when params change — eliminates stale data flash | | scoped | boolean | false | Give each useAsyncChunk caller its own isolated instance instead of sharing this chunk's state — see Scoped Chunks below | | onSuccess | (data: T) => void | — | Called after every successful fetch | | onError | (error: E) => void | — | Called when all retries are exhausted | | retryCount | number | 0 | Number of retries on failure | | retryDelay | number | 1000 | ms between retries | | staleTime | number | 0 | ms before data is considered stale | | cacheTime | number | 300_000 | ms to keep cache after last subscriber leaves | | refetchInterval | number | — | Auto-refetch interval in ms | | refetchOnWindowFocus | boolean | false | Refetch when window regains focus | |

All options except key, enabled, initialData, keepPreviousData, clearOnParamChange, and pagination can be set globally via configureQuery() — per-chunk options always override global defaults.


Conditional fetching with enabled

enabled accepts a boolean or a function that receives the current params. This is the cleanest way to prevent fetching until required data is available:

// Static — never fetches
const chunk = asyncChunk(fetcher, { enabled: false });

// Dynamic — re-evaluated on every setParams call
const flatsByHouseChunk = asyncChunk(
  ({ houseId }: { houseId: string }) => apiGetFlatsByHouse(houseId),
  { enabled: ({ houseId }) => !!houseId },
);

The function receives the same params the fetcher will get — defined once on the chunk, no repetition at the hook callsite.


clearOnParamChange

When navigating between detail views that share a single chunk (e.g. /jobs/abc/jobs/xyz), the chunk holds the previous job's data until the new fetch resolves. Set clearOnParamChange: true to immediately reset data to null when setParams() is called — consumers see a clean { data: null, loading: true } state instantly, with no stale flash.

export const jobDetailChunk = asyncChunk(
  (params: { ref: string }) => api.getJobDetail(params.ref),
  { staleTime: 0, clearOnParamChange: true },
);

clearOnParamChange and keepPreviousData are opposites — enabling both at once is a contradiction. clearOnParamChange wins: data is cleared immediately and keepPreviousData has no effect when both are set.


API reference

reload(params?)

Forces a fresh fetch, ignoring stale time.

await postsChunk.reload();
await postsChunk.reload({ category: "engineering" });

refresh(params?)

Smart refresh — only fetches if data is stale. Does nothing if data is still fresh.

await postsChunk.refresh();

mutate(mutator)

Update data directly without a network request. Useful for optimistic updates.

postsChunk.mutate((current) => [...(current ?? []), newPost]);
// returning null clears data
postsChunk.mutate(() => null);

setParams(params)

Update fetcher params and trigger a fresh fetch. Pass null for a key to remove it.

postsChunk.setParams({ category: "engineering" });
postsChunk.setParams({ category: null }); // removes category key

clearParams()

Wipes all current params and refetches.

postsChunk.clearParams();

cancel()

Cancels any in-flight request and sets loading to false immediately. Subsequent reload() or setParams() calls reset the cancelled state and fetch normally.

postsChunk.cancel();

reset()

Resets to initial state and re-fetches from scratch.

postsChunk.reset();

cleanup()

Safe cleanup — only tears down intervals and listeners if no active subscribers remain.

postsChunk.cleanup();

forceCleanup()

Tears down all side effects regardless of active subscribers.

postsChunk.forceCleanup();

Request deduplication

Use key to deduplicate concurrent requests — if two components call reload() simultaneously on a chunk with the same key, only one request fires:

const userChunk = asyncChunk(fetchUser, { key: "user" });

userChunk.reload();
userChunk.reload(); // joins the same in-flight request


Scoped chunks

By default, an asyncChunk is a singleton — every component that reads it via useAsyncChunk shares the same data, loading, pagination, and params. That's correct for genuinely global state (current user, wallet balance, notification list): a mutation from one place should be visible everywhere.

It's the wrong default for parameterized or filtered lists used by multiple components at once — a search page, a paginated table with a status filter, a per-tab list. Two components sharing one singleton chunk will fight over setParams, nextPage, and cached data, since there's only one shared state underneath.

Set scoped: true to opt a chunk into per-consumer isolation. The export stays exactly the same — no factory function, no useState wrapper needed at the call site:

export const searchResultsChunk = paginatedAsyncChunk(
  async (params: { page: number; pageSize: number; query?: string }) => {
    const res = await api.search(params);
    return { data: res.items, hasMore: res.hasMore };
  },
  {
    pagination: { pageSize: 20, mode: "replace" },
    scoped: true,
  },
);
function SearchTab() {
  // Each component using this chunk gets its own independent instance —
  // one tab's page/filter state never leaks into another's.
  const { data, setParams, nextPage } = useAsyncChunk(searchResultsChunk);
}

Only useAsyncChunk/useInfiniteAsyncChunk resolve scoped transparently. Calling methods directly on the exported chunk (searchResultsChunk.reload() outside a component) still operates on the one shared singleton instance, since scoping is a hook-level behavior, not a chunk-level state split.

When to use scoped:

Use casescoped
Current user, wallet, notifications — one per appfalse (default)
Paginated/filtered list shown by exactly one screenfalse (default) — fine either way
Same paginated/filtered chunk used by multiple simultaneous components (tabs, split views, tables with independent filters)true

keepPreviousData

When params change, the default behavior shows null while loading. Set keepPreviousData: true to keep showing the previous data instead — no flash of empty state:

const postsChunk = asyncChunk(
  ({ category }: { category: string }) => fetchPosts(category),
  { keepPreviousData: true },
);

// While loading, isPlaceholderData is true and data still shows previous results
postsChunk.setParams({ category: "engineering" });

In React

import { asyncChunk } from "stunk/query";
import { useAsyncChunk } from "stunk/react";

const postsChunk = asyncChunk(async () => {
  const res = await fetch("/api/posts");
  return res.json() as Promise<Post[]>;
});

function PostList() {
  const { data, loading, error, reload } = useAsyncChunk(postsChunk);

  if (loading) return <p>Loading...</p>;
  if (error)
    return (
      <p>
        Error: {error.message} <button onClick={reload}>Retry</button>
      </p>
    );

  return (
    <ul>
      {data?.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

What's next?

On this page