InfiniteQueryBuilder
InfiniteQueryBuilder<TData, TPageParam> is the Flutter binding for infinite (paginated) queries. It manages the full pagination lifecycle in a single widget: fetching the first page on mount, subscribing to all state transitions, and exposing an InfiniteQueryController to trigger additional page loads from scroll listeners, buttons, or RefreshIndicator.
Basic Usage: Forward Scroll
InfiniteQueryBuilder<List<Post>, int>(
queryKey: ['posts'],
fetcher: (page) => api.getPosts(page: page),
options: InfiniteQueryOptions(
initialPageParam: 1,
getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null,
),
builder: (context, state, controller) => switch (state) {
InfiniteInitial() || InfiniteLoading() =>
const Center(child: CircularProgressIndicator()),
InfiniteFailure(:final error) => ErrorWidget('$error'),
InfiniteSuccess(:final data, :final hasNextPage, :final isFetchingNextPage) =>
ListView.builder(
itemCount: data.flatten((p) => p).length + (hasNextPage ? 1 : 0),
itemBuilder: (ctx, i) {
final items = data.flatten((p) => p);
if (i == items.length) {
if (!isFetchingNextPage) controller.fetchNextPage();
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
return PostCard(items[i]);
},
),
},
)
The builder receives three arguments:
context: theBuildContextstate: the currentInfiniteQueryState<TData, TPageParam>(what you have)controller: anInfiniteQueryControllerexposingfetchNextPage,fetchPreviousPage, andrefetch
Props
| Prop | Type | Default | Description |
|---|---|---|---|
queryKey | Object (List or QoraKey) | required | Cache key. Changing it disposes the current observer and starts fresh from page 1. |
fetcher | Future<TData> Function(TPageParam pageParam) | required | Async function that fetches a single page for the given param. Must throw on failure. |
options | InfiniteQueryOptions<TData, TPageParam> | required | Pagination config: initialPageParam, getNextPageParam, getPreviousPageParam, maxPages, baseOptions. |
builder | Widget Function(BuildContext, InfiniteQueryState<TData, TPageParam>, InfiniteQueryController<TData, TPageParam>) | required | Builds the widget tree from the current state and pagination controller. |
client | QoraClient? | null | Override the client from QoraScope. Useful for testing or nested scopes. |
enabled | bool | true | When false, the first-page fetch is not triggered on mount. The widget still observes the cached state. |
How It Works
On mount, InfiniteQueryBuilder:
- Creates an
InfiniteQueryObserverbacked by theQoraClientfrom the nearestQoraScope(or the overriddenclient). - Subscribes to the observer's stream: emitting every
InfiniteQueryStatetransition. - Calls
observer.fetch()to load the first page (unlessenabledisfalse).
When the query is externally invalidated (via client.invalidateInfiniteQuery(key)), the state resets to InfiniteInitial. The builder detects this and automatically re-fetches the first page, so the UI never stays blank waiting for a manual action.
When queryKey changes, the current observer is disposed and a new one is created, starting fresh from initialPageParam.
Patterns
Pull-to-Refresh
Wrap the scroll view in a RefreshIndicator and pass controller.refetch directly to onRefresh:
InfiniteQueryBuilder<List<Post>, int>(
queryKey: ['posts'],
fetcher: (page) => api.getPosts(page: page),
options: InfiniteQueryOptions(
initialPageParam: 1,
getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null,
),
builder: (context, state, controller) {
final items = state is InfiniteSuccess<List<Post>, int>
? state.data.flatten((p) => p)
: <Post>[];
return RefreshIndicator(
onRefresh: controller.refetch,
child: ListView(
// Always scrollable so RefreshIndicator can always be triggered.
physics: const AlwaysScrollableScrollPhysics(),
children: items.map(PostCard.new).toList(),
),
);
},
)
refetch() re-fetches all currently loaded pages in sequence, preserving the page count. Existing data stays visible while pages reload.
Bi-directional Scroll
Chat feeds and timelines often load in both directions. Provide getPreviousPageParam and call controller.fetchPreviousPage() when the user scrolls to the top:
InfiniteQueryBuilder<List<Message>, String?>(
queryKey: ['chat', roomId],
fetcher: (cursor) => api.getMessages(roomId, cursor: cursor),
options: InfiniteQueryOptions(
initialPageParam: null, // Start from the latest message
getNextPageParam: (last, all) => last.olderCursor,
getPreviousPageParam: (first, all) => first.newerCursor,
),
builder: (context, state, controller) => switch (state) {
InfiniteInitial() || InfiniteLoading() =>
const Center(child: CircularProgressIndicator()),
InfiniteFailure(:final error) => ErrorWidget('$error'),
InfiniteSuccess(
:final data,
:final hasPreviousPage,
:final hasNextPage,
:final isFetchingPreviousPage,
:final isFetchingNextPage,
) =>
NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n is ScrollStartNotification) {
if (n.metrics.pixels <= 0 && hasPreviousPage && !isFetchingPreviousPage) {
controller.fetchPreviousPage(); // Newer messages
} else if (n.metrics.atEdge && n.metrics.pixels > 0 &&
hasNextPage && !isFetchingNextPage) {
controller.fetchNextPage(); // Older messages
}
}
return false;
},
child: ListView.builder(
reverse: true,
itemCount: data.flatten((p) => p).length,
itemBuilder: (ctx, i) => MessageBubble(data.flatten((p) => p)[i]),
),
),
},
)
Windowed Paging Display
When maxPages is set, InfiniteSuccess.hasPreviousPage becomes true once the window has slid past the first page. Show a "scroll up to load earlier" indicator:
InfiniteQueryBuilder<List<Post>, int>(
queryKey: ['feed'],
fetcher: (page) => api.getFeed(page: page),
options: InfiniteQueryOptions(
initialPageParam: 1,
getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null,
maxPages: 10,
),
builder: (context, state, controller) {
if (state is! InfiniteSuccess<List<Post>, int>) {
return const Center(child: CircularProgressIndicator());
}
final items = state.data.flatten((p) => p);
return CustomScrollView(
slivers: [
if (state.hasPreviousPage)
SliverToBoxAdapter(
child: TextButton(
onPressed: state.isFetchingPreviousPage
? null
: controller.fetchPreviousPage,
child: const Text('Load earlier posts'),
),
),
SliverList.builder(
itemCount: items.length,
itemBuilder: (ctx, i) => PostCard(items[i]),
),
if (state.hasNextPage)
SliverToBoxAdapter(
child: state.isFetchingNextPage
? const Center(child: CircularProgressIndicator())
: TextButton(
onPressed: controller.fetchNextPage,
child: const Text('Load more'),
),
),
],
);
},
)
Conditional Queries (enabled)
Delay fetching until a dependency is ready: for example, until a user session is available:
InfiniteQueryBuilder<List<Post>, int>(
queryKey: ['feed', userId],
fetcher: (page) => api.getFeed(userId: userId!, page: page),
options: InfiniteQueryOptions(
initialPageParam: 1,
getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null,
),
enabled: userId != null,
builder: (context, state, controller) { ... },
)
When enabled flips from false to true (via setState), the first-page fetch is triggered automatically.
Scroll-triggered Loading
Use a ScrollController listener to trigger the next page as the user approaches the bottom:
class _PostsScreenState extends State<PostsScreen> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
// Populated via a StateSetter or passed down through the widget tree
InfiniteQueryController<List<Post>, int>? _queryController;
void _onScroll() {
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 200) {
_queryController?.fetchNextPage();
}
}
@override
Widget build(BuildContext context) => InfiniteQueryBuilder<List<Post>, int>(
queryKey: ['posts'],
fetcher: (page) => api.getPosts(page: page),
options: InfiniteQueryOptions(
initialPageParam: 1,
getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null,
),
builder: (context, state, controller) {
_queryController = controller;
final items = state is InfiniteSuccess<List<Post>, int>
? state.data.flatten((p) => p)
: <Post>[];
return ListView.builder(
controller: _scrollController,
itemCount: items.length,
itemBuilder: (ctx, i) => PostCard(items[i]),
);
},
);
}
InfiniteQueryController
A stable handle to the underlying InfiniteQueryObserver. The controller reference never changes as long as queryKey and client stay the same, so it is safe to capture in scroll listeners or button callbacks.
Methods
| Method | Signature | Description |
|---|---|---|
fetchNextPage() | Future<void> | Fetch and append the next page. No-op if hasNextPage is false or a fetch is already in progress. |
fetchPreviousPage() | Future<void> | Fetch and prepend the previous page. Requires getPreviousPageParam to be configured. |
refetch() | Future<void> | Re-fetch all currently loaded pages in sequence. Useful for pull-to-refresh. |
Key Changing Behaviour
When queryKey changes (didUpdateWidget), InfiniteQueryBuilder:
- Cancels the stream subscription.
- Disposes the current
InfiniteQueryObserver. - Creates a new observer for the new key.
- Subscribes and fetches from
initialPageParam.
This is how feed-switching (e.g. Home / Following tabs) is handled automatically without any extra state management.
Next Steps
- Infinite Queries Guide: Core concepts, state machine, and pagination strategies
- Infinite Query API Reference: Full reference for all infinite query types and client methods
- QoraBuilder: Single-page (non-paginated) query widget