Reference/Pagination & Infinite Queries

Pagination & Infinite Queries

Fetchium supports cursor-based, offset-based, and URL-based pagination through the fetchNext configuration on queries. When fetchNext is configured, the query result exposes __fetchNext(), __hasNext, and __isFetchingNext --- giving you everything you need to build infinite scroll, "load more" buttons, and paginated lists.


How Pagination Works

Pagination in Fetchium is declarative. You define how to fetch the next page on your query class, and Fetchium handles the rest: resolving cursor values from the current response, executing the next-page request, and merging results.

The flow is:

  1. Initial query fetches the first page.
  2. The response includes pagination metadata (a cursor, an offset, or a next URL).
  3. __fetchNext() reads the pagination metadata from the current result, constructs the next request, and fetches it.
  4. Results are merged --- live arrays append new entities, while plain arrays are replaced.
  5. The pagination metadata is updated to reflect the new response, so the next __fetchNext() call fetches the correct page.

Configuring Pagination

Add a fetchNext field to your RESTQuery class. It accepts two optional properties:

PropertyTypeDescription
urlstring or FieldRefOverride the URL for the next page request
searchParamsRecord<string, unknown>Search parameters to append. Values can be FieldRefs.

Cursor-based pagination

The most common pattern. Your API returns a cursor in the response body, and you pass it as a search param on the next request.

tsx
import { t, Entity } from 'fetchium';
import { RESTQuery } from 'fetchium/rest';

class Item extends Entity {
  __typename = t.typename('Item');
  id = t.id;

  name = t.string;
}

class GetItems extends RESTQuery {
  path = '/items';

  result = {
    items: t.liveArray(Item),
    nextCursor: t.optional(t.string),
  };

  fetchNext = {
    searchParams: {
      cursor: this.result.nextCursor,
    },
  };
}

The value this.result.nextCursor is a field reference (FieldRef). At runtime, when __fetchNext() is called, Fetchium resolves the FieldRef against the current result data. If the first response returned { nextCursor: 'abc123' }, the next request will include ?cursor=abc123.

Offset-based pagination

For APIs that use page numbers or offsets:

tsx
class GetItems extends RESTQuery {
  path = '/items';

  result = {
    items: t.array(t.string),
    nextPage: t.optional(t.number),
    limit: t.number,
  };

  fetchNext = {
    searchParams: {
      page: this.result.nextPage,
      limit: this.result.limit,
    },
  };
}

Multiple FieldRefs can be used in the same searchParams object. Each is resolved independently against the current result data.

URL-based pagination

Some APIs return a full URL for the next page. Use the url property instead of searchParams:

tsx
class GetItems extends RESTQuery {
  path = '/items';

  result = {
    items: t.array(t.string),
    nextUrl: t.optional(t.string),
  };

  fetchNext = {
    url: this.result.nextUrl,
  };
}

When __fetchNext() is called, Fetchium fetches the resolved URL directly instead of constructing one from the original path and search params.


Dynamic Pagination with getFetchNext()

For cases where the pagination logic depends on runtime conditions --- response headers, error codes, or computed values --- override getFetchNext() instead of using the static fetchNext field.

tsx
class GetItems extends RESTQuery {
  path = '/items';

  result = { items: t.array(t.string), total: t.number };

  getFetchNext() {
    // Use a page token from response headers if available
    const pageToken = this.response?.headers?.get?.('X-Next-Page-Token');

    if (pageToken) {
      return { searchParams: { pageToken } };
    }

    // Fall back to offset-based pagination
    return { searchParams: { offset: 1 } };
  }
}

getFetchNext() has access to this.response (the raw Response object from the previous fetch) and this.params (the query params). It should return a FetchNextConfig object or undefined.

Return undefined to disable pagination

If getFetchNext() returns undefined, __hasNext will be false and __fetchNext() will throw. Use this to conditionally disable pagination:

tsx
class GetItems extends RESTQuery {
  path = '/items';

  result = {
    items: t.array(t.string),
    hasMore: t.boolean,
    nextPage: t.optional(t.number),
  };

  getFetchNext() {
    // Only allow pagination on successful responses
    if (this.response?.status !== 200) {
      return undefined;
    }
    return { searchParams: { page: 2 } };
  }
}

Priority: getFetchNext() overrides fetchNext

When both a static fetchNext field and a getFetchNext() method are defined, the method takes priority. The static field is ignored.


Using Pagination in Components

tsx
import { useQuery } from 'fetchium/react';

function ItemList() {
  const query = useQuery(GetItems);

  if (query.isPending) return <div>Loading...</div>;

  const result = query.value;

  return (
    <div>
      <ul>
        {result.items.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>

      {result.__hasNext && (
        <button
          onClick={() => result.__fetchNext()}
          disabled={result.__isFetchingNext}
        >
          {result.__isFetchingNext ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Headless usage (outside React)

tsx
import { fetchQuery } from 'fetchium';

const relay = fetchQuery(GetItems);
await relay;

console.log(relay.value.items); // First page items

if (relay.value.__hasNext) {
  await relay.value.__fetchNext();
  console.log(relay.value.items); // Updated items (appended for live arrays)
}

QueryResult Pagination Properties

When fetchNext is configured on a query, the query result object gains three additional properties:

PropertyTypeDescription
__fetchNext()() => PromiseFetches the next page. Returns a promise that resolves when the page is loaded.
__hasNextbooleanWhether more pages are available.
__isFetchingNextbooleanWhether a next-page request is currently in flight.

All three properties are reactive --- reading them inside a Signalium reactive function or a component() establishes a dependency, so your UI updates automatically when the values change.

How __hasNext is determined

For static fetchNext (with FieldRefs), __hasNext is true when all FieldRef values in the searchParams (or url) resolve to non-null, non-undefined values. When the API returns nextCursor: undefined or nextCursor: null, __hasNext becomes false.

For getFetchNext(), __hasNext is true when the method returns a non-undefined config object, and false when it returns undefined.

Deduplication of concurrent calls

Calling __fetchNext() multiple times concurrently returns the same promise. Only one network request is made per page --- subsequent calls while a request is in flight are deduplicated.

tsx
// These are the same promise --- only one request is made
const p1 = result.__fetchNext();
const p2 = result.__fetchNext();
p1 === p2; // true

Append Mode vs Replace Mode

The behavior when a new page is loaded depends on the type of array in your result:

Live arrays (t.liveArray) --- append mode

New entities from the next page are appended to the existing array. The array accumulates across pages, giving you the classic infinite scroll behavior. Duplicate entities (same typename + id) are deduplicated --- the existing entry is updated in place rather than added again.

tsx
class GetItems extends RESTQuery {
  path = '/items';

  result = {
    items: t.liveArray(Item), // Entities accumulate across pages
    nextCursor: t.optional(t.string),
  };

  fetchNext = {
    searchParams: { cursor: this.result.nextCursor },
  };
}

After loading three pages with 2 items each, result.items contains all 6 items (assuming no duplicates).

Plain arrays (t.array) --- replace mode

Plain arrays are replaced with the new page's data. The previous page's items are discarded.

tsx
class GetItems extends RESTQuery {
  path = '/items';

  result = {
    items: t.array(t.string), // Replaced on each fetchNext
    nextPage: t.optional(t.number),
  };

  fetchNext = {
    searchParams: { page: this.result.nextPage },
  };
}

After loading a new page, result.items contains only the new page's items.

Choose the right array type

Use t.liveArray when you want infinite scroll or "load more" behavior where items accumulate. Use t.array when you want traditional pagination where each page replaces the previous one (e.g. a paginated table with "Previous / Next" buttons).


Scalar Field Updates

Non-array fields in the result are always updated to the new page's values. This is how cursor advancement works:

tsx
result = {
  items: t.liveArray(Item),
  nextCursor: t.optional(t.string), // Updated to new cursor on each page
  totalCount: t.number, // Updated to new value on each page
};

After loading the next page:

  • nextCursor is updated to the new cursor value (or undefined/null on the last page).
  • totalCount reflects whatever the new response returned.

If the new response omits an optional field entirely, it becomes undefined. If it explicitly sends null for a nullable field, it becomes null.


Cursor Advancement

FieldRefs automatically resolve to the current result data at the time __fetchNext() is called. This means cursors advance naturally across multiple pages:

tsx
class GetItems extends RESTQuery {
  path = '/items';

  result = {
    items: t.liveArray(Item),
    cursor: t.optional(t.string),
  };

  fetchNext = {
    searchParams: { cursor: this.result.cursor },
  };
}
  1. Initial fetch returns { cursor: 'c1' }.
  2. First __fetchNext() sends ?cursor=c1, response returns { cursor: 'c2' }.
  3. Second __fetchNext() sends ?cursor=c2, response returns { cursor: 'c3' }.
  4. Third __fetchNext() sends ?cursor=c3, response returns { cursor: undefined }.
  5. __hasNext becomes false. No more pages.

You do not need to manually track or update the cursor --- Fetchium handles it automatically through the FieldRef resolution mechanism.


Error Handling

If a __fetchNext() request fails (network error, server error, etc.), the promise rejects and the existing data is preserved. The array is not corrupted, and the cursor is not advanced.

tsx
try {
  await result.__fetchNext();
} catch (error) {
  // The error is from the failed fetch
  console.error('Failed to fetch next page:', error);
  // result.items still contains the previous pages' data
  // result.__hasNext is still true (cursor was not advanced)
}

This means you can safely retry by calling __fetchNext() again after a failure --- it will use the same cursor value since the result data was not updated.


Edge Cases

Calling __fetchNext() before initial data loads

If you call __fetchNext() before the initial query has resolved, it throws an error:

Cannot call __fetchNext before initial data has loaded

Always check query.isReady or query.isPending before accessing __fetchNext().

Calling __fetchNext() without pagination configured

If neither fetchNext nor getFetchNext() is defined on the query class, calling __fetchNext() throws:

fetchNext is not configured

In this case, __hasNext is always false and __isFetchingNext is always false.

Combining fetchNext with searchParams

When fetchNext provides additional searchParams, they are merged with the query's base search params (from the searchParams field or getSearchParams() method). The fetchNext params take priority for any overlapping keys.


Complete Example

Here is a full example showing cursor-based pagination with a live array, entity normalization, and a "load more" UI:

tsx
import { Entity, t } from 'fetchium';
import { RESTQuery } from 'fetchium/rest';
import { useQuery } from 'fetchium/react';

// Entity definition
class Post extends Entity {
  __typename = t.typename('Post');
  id = t.id;

  title = t.string;
  body = t.string;
  author = t.entity(User);
  createdAt = t.format('date-time');
}

// Query with pagination
class GetPosts extends RESTQuery {
  params = { userId: t.number };

  path = `/users/${this.params.userId}/posts`;

  result = {
    posts: t.liveArray(Post),
    nextCursor: t.nullish(t.string),
  };

  fetchNext = {
    searchParams: {
      cursor: this.result.nextCursor,
    },
  };

  config = {
    staleTime: 30_000,
  };
}

// React component
function UserPosts({ userId }: { userId: number }) {
  const query = useQuery(GetPosts, { userId });

  if (query.isPending) return <div>Loading posts...</div>;
  if (query.isRejected) return <div>Error: {query.error.message}</div>;

  const { posts, __hasNext, __isFetchingNext, __fetchNext } = query.value;

  return (
    <div>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.body}</p>
          <span>By {post.author.name}</span>
        </article>
      ))}

      {__hasNext && (
        <button onClick={() => __fetchNext()} disabled={__isFetchingNext}>
          {__isFetchingNext ? 'Loading...' : 'Load More Posts'}
        </button>
      )}

      {!__hasNext && posts.length > 0 && <p>You have reached the end.</p>}
    </div>
  );
}

Next Steps

Live Data

Learn how live arrays and live values keep your UI in sync

Queries

Full reference for query definitions, caching, and configuration

Types

The full type system for params, results, and entity fields

Entities

Normalized entity caching and identity-stable proxies

Previous
REST Queries