Flutter Infinite Scroll Pagination: Load More on Scroll

Coding Liquids blog cover featuring Sagnik Bhattacharya for Flutter Infinite Scroll Pagination: Load More on Scroll, with scrollcontroller and notificationlistener triggers, _page and _limit pagination with append-only results, and loading and hasmore flags plus a bottom loading row.
Coding Liquids blog cover featuring Sagnik Bhattacharya for Flutter Infinite Scroll Pagination: Load More on Scroll, with scrollcontroller and notificationlistener triggers, _page and _limit pagination with append-only results, and loading and hasmore flags plus a bottom loading row.

Watch the Coding Liquids walkthroughs when you want to see loading and retry rows change during a real fling.

Subscribe on YouTube@codingliquids

How an infinite Flutter list grows

Infinite scrolling keeps one mutable list and appends pages instead of replacing earlier rows. The first request fills the list, while later requests start from the next page number and preserve the current scroll offset.

Replacing the list after every response makes the viewport jump and defeats the pattern.

Trigger the next page 200 pixels early

A ScrollController exposes position.pixels and position.maxScrollExtent after the ListView has dimensions. Register a listener in initState and call the loader when pixels is at least maxScrollExtent minus 200.

An equality check against maxScrollExtent is brittle because fast flings can pass the exact value.

final controller = ScrollController();
final items = <Post>[];
int page = 1;
bool loading = false, hasMore = true;

Future<void> loadMore() async {
  if (loading || !hasMore) return;
  loading = true;
  final batch = await repository.fetchPage(page: page, limit: 20);
  items.addAll(batch);
  page++;
  hasMore = batch.length == 20;
  loading = false;
}

void onScroll() {
  if (controller.position.extentAfter < 500) loadMore();
}

Short pagination diagrams on Instagram trace the cursor, loading row, and end-of-list flag through one fetch.

Follow me on Instagram@sagnikteaches

Append _page and _limit results safely

Send _page and _limit as query parameters, decode the returned array, and call addAll on the existing items. Increment the page only after a successful response so Retry repeats the failed page rather than skipping it.

Clearing good rows when page four fails turns a local pagination problem into a full-screen outage.

The Complete Flutter Guide course thumbnail

Build production-ready Infinite Scroll Pagination features

The Complete Flutter Guide turns infinite scroll pagination into maintainable app architecture, polished UI, and testable production code.

Enrol now

Install the pagination package before replacing the manual controller with PagingController and PagedListView.

flutter pub add infinite_scroll_pagination

import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';

Stop duplicate and past-the-end requests

isLoading rejects a second threshold event while a request is active, and hasMore rejects every call after the final batch. Set hasMore to false when the server returns fewer rows than the requested limit or an explicit next cursor is absent.

Scroll notifications arrive repeatedly near the bottom, so an unguarded loader can request the same page several times.

Reserve the last row for progress

Give ListView.builder items.length plus one rows while another page is loading. Return a compact centred CircularProgressIndicator for the extra index and normal tiles for every lower index.

Do not leave the synthetic row in itemCount after loading finishes or the builder will index beyond the data.

Widget buildFeed() {
  return ListView.builder(
    controller: _scrollController,
    itemCount: _items.length + ((_isLoading || _pageError != null) ? 1 : 0),
    itemBuilder: (context, index) {
      if (index < _items.length) return ProductTile(product: _items[index]);
      if (_pageError != null) {
        return ListTile(
          title: Text(_pageError!),
          trailing: TextButton(onPressed: _fetchPage, child: const Text('Retry')),
        );
      }
      return const Padding(
        padding: EdgeInsets.all(16),
        child: Center(child: CircularProgressIndicator()),
      );
    },
  );
}

My LinkedIn write-up examines why request ownership matters when filters change halfway through a scroll.

Connect on LinkedInSagnik Bhattacharya
The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Retry a failed page inside the list

Keep the pagination error beside the accumulated items and render an inline retry tile at the bottom. The retry button calls the same page loader without incrementing the page and disappears when that call succeeds.

A full-screen error hides valid earlier pages and makes users lose their place.

late final PagingController<int, Product> pagingController = PagingController(
  getNextPageKey: (state) => state.lastPageIsEmpty ? null : state.nextIntPageKey,
  fetchPage: (pageKey) => repository.fetchProducts(page: pageKey, limit: 20),
);

PagingListener(
  controller: pagingController,
  builder: (context, state, fetchNextPage) => PagedListView<int, Product>(
    state: state,
    fetchNextPage: fetchNextPage,
    builderDelegate: PagedChildBuilderDelegate<Product>(
      itemBuilder: (_, product, __) => ProductTile(product: product),
    ),
  ),
)

Scale up with PagingController and PagedListView

infinite_scroll_pagination models pages, loading, errors, and next-page keys without hand-managed booleans. Use a PagingController with getNextPageKey and fetchPage, then bind its state to PagedListView through PagingListener.

Check the installed package version because the version-five API differs substantially from older firstPageKey examples.

Stress-test the page boundary

A pagination controller deserves tests at the seam between two pages, because that is where duplicates and omissions usually enter. Stub a repository so page one ends with a known identifier and page two begins with another known identifier. After the second response arrives, assert that the combined list preserves order and contains each identifier once. Repeat the test with an empty final page, a short final page, and an error followed by a retry. These cases exercise the bookkeeping that a happy-path scroll cannot prove.

Slow responses reveal a different class of bug. Drag rapidly to the bottom while the first request is still pending, then confirm that only one network call leaves the app. A request token, generation number, or cancellable operation can prevent an old response from updating a newly refreshed list. If filters change during a fetch, discard the response whose query no longer matches the visible controls. Otherwise a valid result for the previous query can appear beneath the new filter and look like a server-side ordering problem.

Cursor APIs need slightly different reasoning from numbered pages. Store the cursor returned by the server rather than deriving it from the number of local rows, and do not advance it until the corresponding page has succeeded. When the backend uses an item timestamp or document snapshot as its cursor, keep that value alongside the request state. Deleting a row locally must not silently alter the cursor used for the next request. For offset APIs, document whether inserts at the top can shift subsequent offsets and create repeated records.

Release testing should include a narrow phone, a tablet, text scaling, an empty account, and a deliberately throttled connection. Watch the loading row enter and leave the semantics tree, verify that retry remains reachable by keyboard, and ensure pull-to-refresh resets both the data and the end-of-list flag. Logging the requested cursor, response size, and current item count makes a production gap diagnosable without recording the sensitive contents of any row.

Backend contracts should state whether a page can legally repeat an item whose data changed between requests. If that can happen, merge by stable ID and update the existing row instead of dropping the new representation. Preserve scroll position when the merge changes content above the viewport, and test that refresh still returns to the product’s chosen starting position.

Common mistakes

  • How an infinite Flutter list grows: In infinite scrolling, replacing the list after every response makes the viewport jump and defeats the pattern; inspect this infinite scrolling cause before changing another infinite scrolling widget.
  • Trigger the next page 200 pixels early: In infinite scrolling, an equality check against maxScrollExtent is brittle because fast flings can pass the exact value; inspect this infinite scrolling cause before changing another infinite scrolling widget.
  • Append _page and _limit results safely: In infinite scrolling, clearing good rows when page four fails turns a local pagination problem into a full-screen outage; inspect this infinite scrolling cause before changing another infinite scrolling widget.
  • Stop duplicate and past-the-end requests: In infinite scrolling, scroll notifications arrive repeatedly near the bottom, so an unguarded loader can request the same page several times; inspect this infinite scrolling cause before changing another infinite scrolling widget.
  • Reserve the last row for progress: In infinite scrolling, do not leave the synthetic row in itemCount after loading finishes or the builder will index beyond the data; inspect this infinite scrolling cause before changing another infinite scrolling widget.
  • Retry a failed page inside the list: In infinite scrolling, a full-screen error hides valid earlier pages and makes users lose their place; inspect this infinite scrolling cause before changing another infinite scrolling widget.

Frequently asked questions

How does how an infinite Flutter list grows work in infinite scrolling?

For infinite scrolling, the starting rule is that infinite scrolling keeps one mutable list and appends pages instead of replacing earlier rows. Apply this infinite scrolling rule first because how an infinite Flutter list grows determines whether the infinite scrolling pattern fits.

Why does append _page and _limit results safely matter for infinite scrolling?

In infinite scrolling, increment the page only after a successful response so Retry repeats the failed page rather than skipping it. Keeping append _page and _limit results safely at the infinite scrolling call site exposes the infinite scrolling return value directly.

What failure should I test first in infinite scrolling?

First reproduce the infinite-scrolling failure in which a synthetic row remains in itemCount after loading finishes and the builder indexes beyond the data. Re-run the boundary case and verify that a progress row exists only while a request is pending, so the builder never indexes beyond the loaded items.

How can I verify infinite scrolling before release?

Exercise scale up with PagingController and PagedListView with real infinite scrolling inputs on every shipped platform. Inspect the final infinite scrolling callback or output; a successful infinite scrolling button tap alone is not proof.

Further reads

Connect infinite scrolling to the surrounding Flutter stack through these published tutorials:

Sources: Flutter framework and Dart API documentation; infinite scrolling examples verified against current stable Flutter.