Examples

Case Study: Infinite Scroll Feed

Build a high-performance social media-style feed with cursor-based pagination, windowed memory management, and instant optimistic post creation.

Objective

Context: An infinite scroll feed sounds simple: load more data as the user scrolls. The production constraints are strict: after 30 minutes of scrolling, your ListView holds 2,000 widgets, memory spikes, and the OS terminates your app. Alternatively, if you use integer page numbers and a post is inserted mid-scroll, every subsequent page is offset by one and users see duplicates.

This example demonstrates two core controls. Cursor-based pagination eliminates offset drift. Windowed paging with maxPages enforces a bounded in-memory footprint while allowing backward page recovery on demand.


Key Features Demonstrated

  • InfiniteQueryBuilder: accumulates pages and exposes fetchNextPage / fetchPreviousPage through its controller
  • Cursor-based pagination: stable results regardless of server-side insertions
  • maxPages windowed memory management: caps the in-memory page window; drops oldest/newest when exceeded
  • isFetchingNextPage + isFetchingPreviousPage: drives the scroll-end loader without hiding existing content
  • InfiniteData.flatten(): converts the page matrix into a flat item list for SliverList
  • Optimistic post creation: setInfiniteQueryData prepends a new post instantly; rollback on failure
  • refetch(): pull-to-refresh that re-fetches all loaded pages while preserving the page count
  • InfiniteFailure with previousData: load-more errors show a retry button without clearing the feed

Architecture Highlights

The UI layer is built on a CustomScrollView with two slivers: a CupertinoSliverRefreshControl for pull-to-refresh and a SliverList.builder for the feed items. The InfiniteQueryBuilder wraps the scroll view and owns the observer lifecycle.

FeedScreen
└── CustomScrollView
    ├── CupertinoSliverRefreshControl   ← controller.refetch() on pull
    └── SliverList.builder
        ├── items = data.flatten((p) => p.posts)   ← flat list from all pages
        ├── [last item]
        │   ├── hasNextPage → LoadMoreTrigger       ← fires controller.fetchNextPage()
        │   └── isFetchingNextPage → PageLoader     ← spinner at list bottom
        └── InfiniteFailure with previousData → ErrorRetryBanner

The InfiniteQueryBuilder is configured with a String cursor. An empty string means "start from the beginning": the server returns the most recent posts and a nextCursor pointing to older content.

lib/features/feed/ui/feed_screen.dart
InfiniteQueryBuilder<FeedPage, String>(
  queryKey: const ['feed'],
  fetcher: FeedApi.getFeed,
  options: InfiniteQueryOptions<FeedPage, String>(
    initialPageParam: '', // '' = latest posts
    getNextPageParam: (last, _) => last.nextCursor, // older posts
    getPreviousPageParam: (first, _) => first.previousCursor, // non-null only after an eviction
    maxPages: 3, // 3 to see eviction quickly; 8 to 10 in production
    baseOptions: const QoraOptions(
      staleTime: Duration(minutes: 2),
      retryCount: 3,
    ),
  ),
  builder: (context, state, controller) => FeedList(state: state, controller: controller),
)

maxPages bounds the InfiniteData held in memory. The example uses 3 so that eviction is visible after a few pages; at 20 posts per page, a production feed would typically use 8 to 10 (160 to 200 posts).


Implementation Details

Windowed paging: scrolling back in time

When the user scrolls down far enough to load page 4, maxPages: 3 causes page 1 (the newest posts) to be dropped from InfiniteData. The state's hasPreviousPage becomes true. If the user scrolls back up to the top, the SliverList detects this and calls controller.fetchPreviousPage(), which re-fetches the dropped page using getPreviousPageParam.

This entire mechanism: drop, detect and re-fetch is why cursor-based pagination is mandatory with maxPages. Integer page numbers would break here: if 3 new posts were inserted while the user was scrolling, re-fetching "page 1" with ?page=1&limit=20 would return a different 20 posts than the first load. Cursors anchor to a specific server-side position.

lib/features/feed/ui/feed_screen.dart
void _onScroll() {
  final pos = _scrollController.position;

  // Trigger next page when within 300px of the bottom
  if (pos.extentAfter < 300 &&
      _latestState case InfiniteSuccess(:final hasNextPage, :final isFetchingNextPage)
          when hasNextPage && !isFetchingNextPage) {
    controller.fetchNextPage();
  }

  // Trigger previous page when within 300px of the top
  // (only relevant after maxPages eviction has dropped page 1)
  if (pos.extentBefore < 300 &&
      _latestState case InfiniteSuccess(:final hasPreviousPage, :final isFetchingPreviousPage)
          when hasPreviousPage && !isFetchingPreviousPage) {
    controller.fetchPreviousPage();
  }
}

Optimistic post creation with setInfiniteQueryData

When the user creates a new post, it should appear at the top of the feed instantly: before the server responds. Qora exposes setInfiniteQueryData to write directly into the infinite cache:

lib/features/feed/ui/compose_sheet.dart
Future<void> _publish(String content) async {
  final client = context.qora;
  const feedKey = ['feed'];

  // 1. Snapshot for rollback
  final snapshot = client.getInfiniteQueryData<FeedPage, String?>(feedKey);

  // 2. Optimistic prepend to first page
  if (snapshot != null) {
    client.setInfiniteQueryData<FeedPage, String?>(
      feedKey,
      InfiniteData(
        pages: [
          FeedPage(
            posts: [
              Post.optimistic(
                id: 'temp-${DateTime.now().millisecondsSinceEpoch}',
                content: content,
                author: currentUser,
              ),
              ...snapshot.pages.first.posts,
            ],
            nextCursor: snapshot.pages.first.nextCursor,
            previousCursor: null, // optimistic post has no stable cursor yet
          ),
          ...snapshot.pages.skip(1),
        ],
        pageParams: snapshot.pageParams,
      ),
    );
  }

  try {
    // 3. Fire the real mutation
    await api.createPost(content);

    // 4. Invalidate: replace the optimistic item with the server response
    //    refetch() re-fetches all current pages preserving the page count
    await controller.refetch();
  } catch (error) {
    // 5. Rollback: restore the exact pre-mutation snapshot
    if (snapshot != null) {
      client.setInfiniteQueryData(feedKey, snapshot);
    }
    _showError(error);
  }
}

The critical detail here is step 4: calling controller.refetch() rather than invalidateInfiniteQuery. refetch() re-fetches all currently loaded pages (8 pages × 20 items = 160 posts re-validated in sequence), which replaces the temp-ID optimistic post with the real server object. invalidateInfiniteQuery would reset the state to InfiniteInitial and restart from page 1, losing the user's scroll position.

InfiniteFailure with graceful degradation

Load-more errors must never erase the feed the user already loaded. InfiniteFailure preserves previousData: the last successful InfiniteData: for exactly this purpose:

lib/features/feed/ui/feed_screen.dart
case InfiniteFailure(:final error, :final previousData):
  if (previousData != null) {
    final posts = previousData.flatten((p) => p.posts);
    return SliverList.builder(
      itemCount: posts.length + 1, // +1 for the error footer
      itemBuilder: (context, i) {
        if (i < posts.length) return PostTile(post: posts[i]);
        // Error footer at the very end
        return _LoadMoreErrorBanner(
          error: error,
          onRetry: controller.fetchNextPage,
        );
      },
    );
  }
  // No data at all: full error screen
  return SliverFillRemaining(child: ErrorScreen(error));

The flatten call

InfiniteData<FeedPage, String?> holds a List<FeedPage>: one item per loaded page. flatten extracts the inner list from each page and concatenates them:

lib/features/feed/ui/feed_screen.dart
case InfiniteSuccess(:final data, :final hasNextPage, :final isFetchingNextPage):
  final posts = data.flatten((page) => page.posts); // List<Post>

  return SliverList.builder(
    itemCount: posts.length + 1,
    itemBuilder: (context, index) {
      if (index < posts.length) return PostTile(post: posts[index]);

      // Footer: spinner, "load more" trigger, or end-of-feed message
      if (isFetchingNextPage) return const _PageLoader();
      if (hasNextPage) return _LoadMoreButton(onTap: controller.fetchNextPage);
      return const _EndOfFeedBanner();
    },
  );

Source Code

examples/ex_03_infinite_scroll/
├── lib/
│   ├── main.dart                              ← QoraClient + QoraScope setup
│   ├── features/
│   │   └── feed/
│   │       ├── data/
│   │       │   └── feed_api.dart              ← cursor-based HTTP calls
│   │       ├── models/
│   │       │   ├── feed_page.dart             ← { posts, nextCursor, previousCursor }
│   │       │   └── post.dart                  ← Post.optimistic() constructor
│   │       └── ui/
│   │           ├── feed_screen.dart           ← InfiniteQueryBuilder + scroll logic
│   │           ├── post_tile.dart             ← single post widget
│   │           ├── compose_sheet.dart         ← optimistic post creation
│   │           └── widgets/
│   │               ├── page_loader.dart       ← bottom-of-list spinner
│   │               ├── load_more_error.dart   ← retry banner on page failure
│   │               └── end_of_feed.dart       ← "You're all caught up" banner
└── pubspec.yaml

View full source code


Performance Notes

ConcernSolution
Memory bloat from long sessionsmaxPages caps the page window; evicted pages are re-fetched on demand
Scroll jank from large listsSliverList.builder renders only visible items; InfiniteData object itself is bounded
Cursor drift from server insertsCursor-based pagination anchors each page to a stable server-side position
Thundering-herd on refetch()Pages are re-fetched sequentially, not concurrently
Stale feed on return to tabstaleTime: 2 minutes triggers a background refetch() on tab re-focus via FlutterLifecycleManager

Copyright © 2026