Flutter Integration

InfiniteQueryBuilder

The widget that manages paginated queries and exposes fetchNextPage, fetchPreviousPage, and refetch through a stable controller interface.

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

posts_screen.dart
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:

  1. context: the BuildContext
  2. state: the current InfiniteQueryState<TData, TPageParam> (what you have)
  3. controller: an InfiniteQueryController exposing fetchNextPage, fetchPreviousPage, and refetch

Props

PropTypeDefaultDescription
queryKeyObject (List or QoraKey)requiredCache key. Changing it disposes the current observer and starts fresh from page 1.
fetcherFuture<TData> Function(TPageParam pageParam)requiredAsync function that fetches a single page for the given param. Must throw on failure.
optionsInfiniteQueryOptions<TData, TPageParam>requiredPagination config: initialPageParam, getNextPageParam, getPreviousPageParam, maxPages, baseOptions.
builderWidget Function(BuildContext, InfiniteQueryState<TData, TPageParam>, InfiniteQueryController<TData, TPageParam>)requiredBuilds the widget tree from the current state and pagination controller.
clientQoraClient?nullOverride the client from QoraScope. Useful for testing or nested scopes.
enabledbooltrueWhen false, the first-page fetch is not triggered on mount. The widget still observes the cached state.

How It Works

On mount, InfiniteQueryBuilder:

  1. Creates an InfiniteQueryObserver backed by the QoraClient from the nearest QoraScope (or the overridden client).
  2. Subscribes to the observer's stream: emitting every InfiniteQueryState transition.
  3. Calls observer.fetch() to load the first page (unless enabled is false).

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:

posts_screen.dart
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:

chat_screen.dart
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:

feed_screen.dart
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:

user_feed_screen.dart
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:

posts_screen.dart
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

MethodSignatureDescription
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:

  1. Cancels the stream subscription.
  2. Disposes the current InfiniteQueryObserver.
  3. Creates a new observer for the new key.
  4. 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

Copyright © 2026