Recipes

Infinite Queries

Implement forward and bi-directional pagination with InfiniteQueryObserver: load more on scroll, pull-to-refresh, windowed paging, and cursor-based APIs.

An infinite query fetches data in pages and accumulates results across requests. Pages are appended or prepended instead of replacing existing data.

Qora implements this pattern with InfiniteQueryObserver, InfiniteData, and InfiniteQueryState.


The State Machine

InfiniteInitial
      │
      │  observer.fetch()
      ▼
InfiniteLoading         ← initial page load only
      │
      ├── success ──▶ InfiniteSuccess
      │                    │
      │                    ├── fetchNextPage()     ──▶ InfiniteSuccess (isFetchingNextPage: true)
      │                    ├── fetchPreviousPage() ──▶ InfiniteSuccess (isFetchingPreviousPage: true)
      │                    └── fetch failure       ──▶ InfiniteFailure (with previousData)
      │
      └── failure ──▶ InfiniteFailure

Key design: after the first page is loaded, the state never returns to InfiniteLoading. Subsequent page fetches signal their progress via isFetchingNextPage / isFetchingPreviousPage on InfiniteSuccess, so existing pages stay visible while more data loads.


Basic Setup

posts_screen.dart
final observer = InfiniteQueryObserver<List<Post>, int>(
  client: client,
  key: ['posts'],
  fetcher: (page) => api.getPosts(page: page),
  options: InfiniteQueryOptions(
    initialPageParam: 1,
    // Return null when there are no more pages.
    getNextPageParam: (lastPage, allPages) =>
        lastPage.hasMore ? allPages.length + 1 : null,
  ),
);

// Fetch the first page
await observer.fetch();

// Subscribe to state changes
observer.stream.listen((state) {
  switch (state) {
    case InfiniteInitial() || InfiniteLoading():
      showSpinner();
    case InfiniteSuccess(:final data, :final hasNextPage):
      final posts = data.flatten((page) => page);
      renderList(posts, hasNextPage: hasNextPage);
    case InfiniteFailure(:final error):
      showError(error);
  }
});

// Later: trigger next page (e.g. from a scroll listener)
await observer.fetchNextPage();

// Always dispose when done
observer.dispose();

InfiniteData<TData, TPageParam>

InfiniteData is an immutable container that holds all loaded pages and the parameters used to fetch them:

state.data.pages        // List<TData>: all pages in order
state.data.pageParams   // List<TPageParam>: params used for each page
state.data.pageCount    // int
state.data.isEmpty      // bool

flatten: from pages to items

flatten converts a list of pages into a flat item list for ListView.builder:

posts_screen.dart
// TData == List<Post>
final posts = data.flatten((page) => page);

// TData is a response object with a nested list
final posts = data.flatten((page) => page.items);

InfiniteQueryOptions

OptionTypeRequiredDescription
initialPageParamTPageParamYesParameter for the very first page. Typically 1, 0, or an empty cursor.
getNextPageParamTPageParam? Function(TData lastPage, List<TData> allPages)YesReturn the next page's parameter, or null when no more pages exist.
getPreviousPageParamTPageParam? Function(TData firstPage, List<TData> allPages)?:Return the previous page's parameter. Only needed for bi-directional scroll.
maxPagesint?:Maximum pages kept in memory (windowed paging). When exceeded, the oldest page is dropped.
baseOptionsQoraOptions:Standard retry, stale time, and network mode options applied to every page fetch.

Pagination Strategies

Integer page numbers

posts_screen.dart
InfiniteQueryOptions<List<Post>, int>(
  initialPageParam: 1,
  getNextPageParam: (lastPage, allPages) =>
      lastPage.hasMore ? allPages.length + 1 : null,
)

Cursor-based

feed_screen.dart
InfiniteQueryOptions<FeedPage, String?>(
  initialPageParam: null,   // null = "start from beginning"
  getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
)

Offset/limit

search_screen.dart
const pageSize = 20;

InfiniteQueryOptions<List<Result>, int>(
  initialPageParam: 0,
  getNextPageParam: (lastPage, allPages) {
    final nextOffset = allPages.length * pageSize;
    return lastPage.length == pageSize ? nextOffset : null;
  },
)

Bi-directional Scroll

Chat feeds, timelines, and comment threads often need to load in both directions. Provide getPreviousPageParam to enable backward paging:

chat_screen.dart
InfiniteQueryOptions<List<Message>, String?>(
  initialPageParam: null,   // Start from the latest message
  getNextPageParam: (lastPage, allPages) => lastPage.olderCursor,
  getPreviousPageParam: (firstPage, allPages) => firstPage.newerCursor,
)
chat_screen.dart
// Scroll up → load older messages
await observer.fetchPreviousPage();

// Scroll down → load newer messages
await observer.fetchNextPage();

Windowed Paging (maxPages)

For long-running scroll sessions (Twitter/X-like feeds), an unbounded page list causes increasing memory usage and UI jank. maxPages limits the in-memory window:

feed_screen.dart
InfiniteQueryOptions<List<Post>, int>(
  initialPageParam: 1,
  getNextPageParam: (lastPage, allPages) =>
      lastPage.hasMore ? allPages.length + 1 : null,
  maxPages: 10,   // Keep at most 10 pages in memory
)

When the window slides:

  • fetchNextPage: drops the oldest page from the front.
  • fetchPreviousPage: drops the newest page from the back.

The dropped edge becomes scrollable again: when the user reaches it, hasPreviousPage (or hasNextPage) becomes true and Qora re-fetches the dropped page using getPreviousPageParam (or getNextPageParam). This requires your API to support random-access parameters (stable page numbers or cursors).

For most apps, maxPages: 5–10 provides a good balance. Each page holds 20–50 items, so 10 pages ≈ 200–500 items in memory: well within Flutter's capabilities.

Refetch After Invalidation

When data becomes stale (e.g. a mutation changes server state), call refetch():

posts_screen.dart
// After creating a new post
await api.createPost(title);
await observer.refetch();   // Refetches all loaded pages in sequence

refetch() re-fetches every page in the current window using its original pageParams, preserving the page count. It signals progress via isFetchingNextPage: true on the existing InfiniteSuccess state: existing data stays visible.

You can also invalidate from anywhere in the app:

anywhere.dart
client.invalidateInfiniteQuery(['posts']);
// The observer detects InfiniteInitial and re-fetches automatically

Error Handling

Errors during fetchNextPage or fetchPreviousPage transition to InfiniteFailure while preserving previousData:

posts_screen.dart
case InfiniteFailure(:final error, :final previousData):
  if (previousData != null) {
    // Show existing content + an error banner
    return Column(children: [
      PostList(items: previousData.flatten((p) => p)),
      ErrorBanner(message: 'Failed to load more: $error'),
    ]);
  }
  // No data loaded yet: show full error screen
  return ErrorScreen(error);

Retry is automatic for page fetches (controlled by baseOptions.retryCount, default 3). For the Flutter binding, see InfiniteQueryBuilder.


Optimistic Updates

You can write directly to the infinite cache for immediate UI feedback:

posts_screen.dart
// Snapshot before mutation
final snapshot = client.getInfiniteQueryData<List<Post>, int>(['posts']);

// Optimistically prepend the new post to the first page
if (snapshot != null) {
  final optimisticData = InfiniteData<List<Post>, int>(
    pages: [
      [Post.optimistic(title), ...snapshot.pages.first],
      ...snapshot.pages.skip(1),
    ],
    pageParams: snapshot.pageParams,
  );
  client.setInfiniteQueryData(['posts'], optimisticData);
}

try {
  await api.createPost(title);
  client.invalidateInfiniteQuery(['posts']);
} catch (_) {
  // Rollback
  if (snapshot != null) client.setInfiniteQueryData(['posts'], snapshot);
}

Next Steps

Copyright © 2026