Case Study: Infinite Scroll Feed
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 exposesfetchNextPage/fetchPreviousPagethrough its controller- Cursor-based pagination: stable results regardless of server-side insertions
maxPageswindowed memory management: caps the in-memory page window; drops oldest/newest when exceededisFetchingNextPage+isFetchingPreviousPage: drives the scroll-end loader without hiding existing contentInfiniteData.flatten(): converts the page matrix into a flat item list forSliverList- Optimistic post creation:
setInfiniteQueryDataprepends a new post instantly; rollback on failure refetch(): pull-to-refresh that re-fetches all loaded pages while preserving the page countInfiniteFailurewithpreviousData: 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.
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.
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:
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:
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:
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
Performance Notes
| Concern | Solution |
|---|---|
| Memory bloat from long sessions | maxPages caps the page window; evicted pages are re-fetched on demand |
| Scroll jank from large lists | SliverList.builder renders only visible items; InfiniteData object itself is bounded |
| Cursor drift from server inserts | Cursor-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 tab | staleTime: 2 minutes triggers a background refetch() on tab re-focus via FlutterLifecycleManager |
Related Guides
- Infinite Queries Guide:
InfiniteData,InfiniteQueryObserverfor pure Dart, windowed paging, and bi-directional scroll - InfiniteQueryBuilder: Flutter widget binding
- Optimistic Updates Guide:
setQueryData,getQueryData,restoreQueryData - Infinite Query API Reference: full method reference
Case Study: Optimistic Mutations & Rollback
A complete optimistic update implementation: setQueryData for immediate UI feedback, restoreQueryData for automatic rollback on failure, and invalidateWhere for server reconciliation.
Case Study: Offline-First Todo App
A todo app that keeps working without a network: saved data on start, queries that pause offline, and writes queued and replayed in order on reconnect.