Spoosh
Guides

Infinite Queries

Load more data as the user scrolls with usePages

usePages is designed for infinite scroll interfaces where new data appends to an ever-growing list as the user scrolls down.

For complete API reference, see usePages.

How It Works

usePages doesn't assume any specific response shape — you tell it how to read your API's response via callback functions. The nextPageRequest function returns only the fields that change between pages. These are automatically merged with the original request, so you don't need to spread existing query parameters.

Given an API that returns:

{
  "items": [
    { "id": 1, "title": "Post 1" },
    { "id": 2, "title": "Post 2" }
  ],
  "meta": { "page": 1, "hasMore": true }
}

You map those fields in the options:

const { data, canFetchNext, fetchNext, fetchingNext } = usePages(
  (api) => api("posts").GET({ query: { page: 1, limit: 20 } }),
  {
    canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,
    nextPageRequest: ({ lastPage }) => ({
      query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
    }),
    merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
  }
);

Since the original request already has { page: 1, limit: 20 }, returning { query: { page: 2 } } from nextPageRequest merges into { page: 2, limit: 20 } automatically.

The merger Function

As pages load, each page is collected into an array. The merger function receives all pages and combines them into a single flat list that data returns:

// After loading 3 pages, pages contains:
// [
//   { status: "success", data: { items: [{id:1}, {id:2}], meta: {page:1, hasMore:true} } },
//   { status: "success", data: { items: [{id:3}, {id:4}], meta: {page:2, hasMore:true} } },
//   { status: "success", data: { items: [{id:5}],         meta: {page:3, hasMore:false} } },
// ]

merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
// data → [{id:1}, {id:2}, {id:3}, {id:4}, {id:5}]

If you need access to the raw pages (e.g., for page metadata or per-page status), use pages from the hook return:

const { data, pages } = usePages(...);

// data  → merged flat list from merger()
// pages → array of page objects with status, data, error, and meta

Basic Setup

function PostFeed() {
  const { data, loading, canFetchNext, fetchNext, fetchingNext } =
    usePages(
      (api) => api("posts").GET({ query: { page: 1, limit: 20 } }),
      {
        canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,
        nextPageRequest: ({ lastPage }) => ({
          query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
        }),
        merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
      }
    );

  if (loading) return <div>Loading...</div>;

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

      {canFetchNext && (
        <button onClick={fetchNext} disabled={fetchingNext}>
          {fetchingNext ? "Loading..." : "Load More"}
        </button>
      )}
    </div>
  );
}

Auto-Loading with Intersection Observer

Replace the "Load More" button with automatic loading when the user scrolls near the bottom:

import { useRef, useEffect } from "react";

function InfinitePostFeed() {
  const sentinelRef = useRef<HTMLDivElement>(null);

  const { data, loading, canFetchNext, fetchNext, fetchingNext } =
    usePages(
      (api) => api("posts").GET({ query: { page: 1, limit: 20 } }),
      {
        canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,
        nextPageRequest: ({ lastPage }) => ({
          query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
        }),
        merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
      }
    );

  useEffect(() => {
    const sentinel = sentinelRef.current;

    if (!sentinel) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && canFetchNext && !fetchingNext) {
          fetchNext();
        }
      },
      { threshold: 0.1 }
    );

    observer.observe(sentinel);

    return () => observer.disconnect();
  }, [canFetchNext, fetchingNext, fetchNext]);

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      {data?.map((post) => <PostCard key={post.id} post={post} />)}
      <div ref={sentinelRef}>
        {fetchingNext && <div>Loading more...</div>}
      </div>
    </div>
  );
}

Cursor-Based Scrolling

For APIs that return a cursor instead of page numbers:

{
  "items": [{ "id": 10, "title": "Post 10" }],
  "nextCursor": "abc123"
}
const { data, canFetchNext, fetchNext, fetchingNext } = usePages(
  (api) => api("feed").GET({ query: { limit: 20 } }),
  {
    canFetchNext: ({ lastPage }) => !!lastPage?.data?.nextCursor,
    nextPageRequest: ({ lastPage }) => ({
      query: { cursor: lastPage?.data?.nextCursor },
    }),
    merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
  }
);

Bidirectional Scrolling

Load content in both directions — useful for chat threads or timelines where the user starts in the middle:

const {
  data,
  canFetchNext,
  canFetchPrev,
  fetchNext,
  fetchPrev,
  fetchingNext,
  fetchingPrev,
} = usePages((api) => api("messages").GET({ query: { page: 5, limit: 20 } }), {
  canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,
  nextPageRequest: ({ lastPage }) => ({
    query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
  }),
  canFetchPrev: ({ firstPage }) => (firstPage?.data?.meta.page ?? 1) > 1,
  prevPageRequest: ({ firstPage }) => ({
    query: { page: (firstPage?.data?.meta.page ?? 2) - 1 },
  }),
  merger: (pages) => pages.flatMap((p) => p.data?.messages ?? []),
});
function MessageThread() {
  return (
    <div>
      {canFetchPrev && (
        <button onClick={fetchPrev} disabled={fetchingPrev}>
          Load Older
        </button>
      )}

      {data?.map((msg) => <Message key={msg.id} message={msg} />)}

      {canFetchNext && (
        <button onClick={fetchNext} disabled={fetchingNext}>
          Load Newer
        </button>
      )}
    </div>
  );
}

Combining with Plugins

usePages works with plugins like cache and deduplication. Each page request has its own cache key, and the merged list is derived from these cached responses. Deduplication prevents duplicate requests when scrolling quickly:

const spoosh = new Spoosh<ApiSchema, Error>("/api").use([
  cachePlugin({ staleTime: 5000 }),
  deduplicationPlugin(),
  invalidationPlugin(),
]);

When a mutation invalidates tags that match infinite scroll queries, all loaded pages are refetched automatically. When returning to a previously loaded query (e.g., same parameters), Spoosh reuses cached page responses and rebuilds the merged list automatically.

Trigger Mode

Use the trigger function to start a new fetch with different request options. This is useful for search or filter functionality:

function SearchablePosts() {
  const [search, setSearch] = useState("");

  const { data, trigger, loading } = usePages(
    (api) => api("posts").GET({ query: { page: 1, limit: 20 } }),
    {
      enabled: false, // Don't fetch on mount - use trigger instead
      canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,
      nextPageRequest: ({ lastPage }) => ({
        query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
      }),
      merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
    }
  );

  const handleSearch = () => {
    // Pass new query params to trigger
    // force: false uses cache if same search was done before
    trigger({ query: { search, page: 1, limit: 20 }, force: false });
  };

  return (
    <div>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
      <button onClick={handleSearch}>Search</button>
      {data?.map((post) => <PostCard key={post.id} post={post} />)}
    </div>
  );
}

Trigger Options

OptionTypeDefaultDescription
queryobject-Query parameters to merge
paramsobject-Path parameters to merge
bodyunknown-Request body
forcebooleantrueDelete all endpoint caches (including other search variations) and fetch fresh
// Force refetch (default) - deletes all caches for this endpoint
// This includes caches from different search terms or query params
trigger();

// Use cache if available - keeps existing page caches
trigger({ force: false });

// New search with custom params
trigger({ query: { search: "react", category: "tech" } });

Reactive State Changes

Like useRead, usePages reacts to changes in the request function. When query parameters change, it automatically fetches with the new parameters:

function FilteredPosts({ category }: { category: string }) {
  const { data, loading, canFetchNext, fetchNext } = usePages(
    // When category changes, this triggers a new fetch automatically
    (api) => api("posts").GET({ query: { page: 1, category } }),
    {
      canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,
      nextPageRequest: ({ lastPage }) => ({
        query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
      }),
      merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
    }
  );

  return (
    <div>
      {data?.map((post) => <PostCard key={post.id} post={post} />)}
      {canFetchNext && <button onClick={fetchNext}>Load More</button>}
    </div>
  );
}

Reactive vs Trigger Mode

Choose based on your use case:

ApproachWhen to Use
ReactiveFilter buttons, dropdowns, URL-driven filters
TriggerSearch inputs, manual refresh, form submissions
// Reactive: category from props/state
const { data } = usePages(
  (api) => api("posts").GET({ query: { page: 1, category } }),
  {
    /* options */
  }
);

// Trigger: manual search (force: false to use cache if available)
const { data, trigger } = usePages(
  (api) => api("posts").GET({ query: { page: 1 } }),
  { enabled: false /* options */ }
);
trigger({ query: { search: searchTerm }, force: false });

Refetching All Pages

Use the trigger function without options to reset to the initial request and refetch from the beginning:

const { data, trigger } = usePages(
  (api) => api("posts").GET({ query: { page: 1, limit: 20 } }),
  {
    canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,
    nextPageRequest: ({ lastPage }) => ({
      query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
    }),
    merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
  }
);

// Reset to first page and refetch
<button onClick={() => trigger()}>Refresh All</button>

On this page