StunkStunk
Async

Infinite Async Chunk

Paginated async state with automatic infinite scroll support.

infiniteAsyncChunk() handles paginated data in accumulate mode — each page load appends to the existing list rather than replacing it. It's designed specifically for infinite scroll UIs.

import { infiniteAsyncChunk } from "stunk/query";

const postsChunk = infiniteAsyncChunk(
  async ({ page, pageSize }) => {
    const res = await fetch(`/api/posts?page=${page}&limit=${pageSize}`);
    const json = await res.json();
    return { data: json.posts, hasMore: json.hasMore };
  },
  { pageSize: 20 },
);

Options

OptionTypeDefaultDescription
pageSizenumber10Items per page
keystringautoDeduplication key — concurrent calls share one request
enabledboolean | () => booleantrueDisable fetching until ready
initialDataT[]Seed data before the first fetch
keepPreviousDatabooleanfalseShow previous data while next page loads
onSuccess(data: T[]) => voidCalled with the full accumulated array after each fetch
onError(error: E) => voidCalled when all retries are exhausted
retryCountnumberRetries on failure
retryDelaynumberms between retries
staleTimenumberms before data is considered stale
cacheTimenumberms to keep cache after last subscriber leaves
refetchIntervalnumberAuto-refetch interval in ms
refetchOnWindowFocusbooleanfalseRefetch when window regains focus

API reference

infiniteAsyncChunk inherits all asyncChunk APIs — reload(), refresh(), mutate(), setParams(), clearParams(), reset(), cleanup(), forceCleanup() — and adds the following:

nextPage()

Loads the next page and appends results to existing data. Stops when hasMore is false.

await postsChunk.nextPage();

resetPagination()

Resets to page 1, clears accumulated data, and re-fetches.

await postsChunk.resetPagination();

prevPage() and goToPage() are not available on infiniteAsyncChunk — infinite scroll is forward-only. For bidirectional pagination use asyncChunk directly with pagination: { mode: 'replace' }.


Fetcher shape

page and pageSize are injected automatically — never pass them in setParams:

async ({ page, pageSize, ...yourParams }) => {
  return {
    data: T[],          // required — items for this page
    hasMore?: boolean,  // optional — whether more pages exist
    total?: number,     // optional — total item count
  };
}

In React

Attach observerTarget to a sentinel element at the bottom of your list — the hook handles intersection observation automatically:

import { infiniteAsyncChunk } from "stunk/query";
import { useInfiniteAsyncChunk } from "stunk/react";

const postsChunk = infiniteAsyncChunk(
  async ({ page, pageSize }) => {
    const res = await fetch(`/api/posts?page=${page}&limit=${pageSize}`);
    const json = await res.json();
    return { data: json.posts, hasMore: json.hasMore };
  },
  { pageSize: 20 },
);

function PostFeed() {
  const {
    data,
    loading,
    error,
    hasMore,
    isFetchingMore,
    loadMore,
    observerTarget,
  } = useInfiniteAsyncChunk(postsChunk);

  if (loading && !data?.length) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

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

      <div ref={observerTarget} />

      {isFetchingMore && <p>Loading more...</p>}
      {!hasMore && <p>You've reached the end.</p>}
    </div>
  );
}

useInfiniteAsyncChunk return values

ValueTypeDescription
dataT[] | nullAll accumulated items so far
loadingbooleantrue during any fetch
errorE | nullError from the latest failed fetch
isPlaceholderDatabooleantrue when showing stale data while next page loads
hasMorebooleanWhether more pages are available
isFetchingMorebooleantrue when loading a subsequent page (data already exists)
loadMore() => voidManually trigger the next page
observerTargetRefObject<HTMLElement>Attach to a sentinel element for auto-loading
reload() => Promise<void>Reload from page 1, clearing accumulated data
refresh() => Promise<void>Refresh without resetting pagination

Manual load more

Disable auto-scroll and trigger manually with autoLoad: false:

const { data, loadMore, hasMore, isFetchingMore } = useInfiniteAsyncChunk(
  postsChunk,
  { autoLoad: false },
);

return (
  <div>
    {data?.map((post) => (
      <li key={post.id}>{post.title}</li>
    ))}
    {hasMore && (
      <button onClick={loadMore} disabled={isFetchingMore}>
        {isFetchingMore ? "Loading..." : "Load more"}
      </button>
    )}
  </div>
);

With extra params

const postsChunk = infiniteAsyncChunk<Post, Error, { category: string }>(
  async ({ page, pageSize, category }) => {
    const res = await fetch(
      `/api/posts?page=${page}&limit=${pageSize}&category=${category}`,
    );
    return { data: (await res.json()).posts, hasMore: true };
  },
);

postsChunk.setParams({ category: "engineering" });

page and pageSize are always injected automatically — never pass them in setParams.


What's next?

On this page