flutter
/

GetX Pagination: Infinite Scroll & Load More Data

Last Sync: Today

On this page

10
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

GetX Pagination: Infinite Scroll & Load More Data

Introduction

Pagination is essential when dealing with large datasets. Instead of loading everything at once, you load data in chunks (pages) as the user scrolls. GetX makes it easy to implement pagination with reactive state, handling loading indicators, error states, and pull-to-refresh. This guide covers common patterns for pagination using GetX.

Basic Pagination with Reactive State

Create a controller that holds the list of items, the current page, and a flag to indicate if more data is available. Use reactive variables so the UI updates automatically.

DARTRead-only
1
class PaginationController extends GetxController {
  var items = <String>[].obs;
  var currentPage = 1.obs;
  var hasMore = true.obs;
  var isLoading = false.obs;

  Future<void> fetchItems() async {
    if (isLoading.value || !hasMore.value) return;
    isLoading.value = true;
    try {
      final newItems = await api.getItems(page: currentPage.value);
      if (newItems.isEmpty) {
        hasMore.value = false;
      } else {
        items.addAll(newItems);
        currentPage.value++;
      }
    } catch (e) {
      Get.snackbar('Error', e.toString());
    } finally {
      isLoading.value = false;
    }
  }
}

UI: Infinite Scrolling

In your view, use a ListView.builder with a ScrollController. When the user reaches the end, trigger the next page load.

DARTRead-only
1
class ItemsPage extends GetView<PaginationController> {
  final ScrollController scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    scrollController.addListener(() {
      if (scrollController.position.pixels == scrollController.position.maxScrollExtent) {
        controller.fetchItems();
      }
    });
  }

  @override
  void dispose() {
    scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Pagination')),
      body: Obx(() => ListView.builder(
        controller: scrollController,
        itemCount: controller.items.length + (controller.hasMore.value ? 1 : 0),
        itemBuilder: (_, index) {
          if (index == controller.items.length) {
            return Center(child: CircularProgressIndicator());
          }
          return ListTile(title: Text(controller.items[index]));
        },
      )),
    );
  }
}

Using StateMixin for Pagination

StateMixin can help manage the loading, error, and success states for the entire pagination flow. The list itself is part of the state, and we can still load more without changing the state type.

DARTRead-only
1
class PaginationController extends GetxController with StateMixin<List<String>> {
  var currentPage = 1;
  var hasMore = true;
  var isLoadingMore = false;

  @override
  void onInit() {
    super.onInit();
    fetchItems();
  }

  Future<void> fetchItems({bool refresh = false}) async {
    if (refresh) {
      currentPage = 1;
      hasMore = true;
      change([], status: RxStatus.loading());
    }
    if (isLoadingMore || !hasMore) return;

    isLoadingMore = true;
    try {
      final newItems = await api.getItems(page: currentPage);
      if (newItems.isEmpty) {
        hasMore = false;
      } else {
        final currentList = state ?? [];
        final updatedList = [...currentList, ...newItems];
        currentPage++;
        change(updatedList, status: RxStatus.success());
      }
    } catch (e) {
      change(state, status: RxStatus.error(e.toString()));
    } finally {
      isLoadingMore = false;
    }
  }
}

Pull-to-Refresh with Pagination

Combine RefreshIndicator with your paginated list to allow the user to manually refresh and reset the pagination state.

DARTRead-only
1
RefreshIndicator(
  onRefresh: () => controller.fetchItems(refresh: true),
  child: controller.obx(
    (data) => ListView.builder(
      controller: scrollController,
      itemCount: data!.length + (controller.hasMore ? 1 : 0),
      itemBuilder: (_, i) {
        if (i == data.length) {
          return Center(child: CircularProgressIndicator());
        }
        return ListTile(title: Text(data[i]));
      },
    ),
    onLoading: Center(child: CircularProgressIndicator()),
    onError: (error) => Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text('Error: $error'),
          ElevatedButton(
            onPressed: () => controller.fetchItems(refresh: true),
            child: Text('Retry'),
          ),
        ],
      ),
    ),
  ),
);

Error Handling & Retry

When a page load fails, you can show an error message and provide a retry button. For a better user experience, you can keep the existing items and allow the user to retry the failed page.

DARTRead-only
1
Future<void> fetchItems() async {
  if (isLoading.value) return;
  isLoading.value = true;
  try {
    final newItems = await api.getItems(page: currentPage.value);
    if (newItems.isEmpty) {
      hasMore.value = false;
    } else {
      items.addAll(newItems);
      currentPage.value++;
      error.value = '';
    }
  } catch (e) {
    error.value = e.toString();
    Get.snackbar('Error', error.value);
  } finally {
    isLoading.value = false;
  }
}

// In the builder, show a retry widget instead of the loading indicator
if (controller.error.value.isNotEmpty && index == controller.items.length) {
  return Center(
    child: Column(
      children: [
        Text('Failed to load more'),
        ElevatedButton(
          onPressed: controller.fetchItems,
          child: Text('Retry'),
        ),
      ],
    ),
  );
}

Best Practices

  • Use addListener on ScrollController – Detect when the user reaches the end of the list.
  • Dispose the ScrollController – Prevent memory leaks in your view.
  • Avoid loading more while already loading – Check isLoadingMore to prevent duplicate requests.
  • Reset pagination on refresh – Clear the list and reset the page counter.
  • Show a loading indicator at the bottom – Provide visual feedback when loading the next page.
  • Handle edge cases – Empty lists, no more data, and network errors gracefully.

Common Mistakes

  • ❌ Not checking if hasMore before loading – May attempt to load beyond the last page. ✅ Check hasMore and stop when no more data.
  • ❌ Calling fetchItems multiple times in quick succession – Causes duplicate requests. ✅ Use a loading flag to prevent overlapping calls.
  • ❌ Not resetting the list on refresh – New data will be appended instead of replaced. ✅ Clear the list and reset the page counter.
  • ❌ Forgetting to dispose ScrollController – Causes memory leaks. ✅ Always dispose in dispose method.

FAQ

  • Q: How do I implement pagination with a GridView?
    A: Same principle – use a GridView.builder with the same ScrollController and item count logic.
  • Q: How to handle pagination when the API uses page size and offset?
    A: Maintain page and limit variables; when fetching, pass page and limit. Increment page after successful fetch.
  • Q: Can I use GetX widgets like Obx with pagination?
    A: Yes, Obx works perfectly. However, be careful with large lists; use ListView.builder to avoid building all items at once.
  • Q: How do I show a loading indicator at the bottom without affecting the scroll position?
    A: Add an extra item at the end of the list that shows the loading indicator only when isLoadingMore is true.
  • Q: How to implement pagination with a search filter?
    A: Reset the pagination state (page = 1, clear list) when the filter changes, then fetch new data.

Conclusion

Pagination with GetX is straightforward. By combining reactive state, scroll controllers, and proper state management, you can create smooth infinite scrolling experiences. Remember to handle loading states, errors, and edge cases to keep your app robust.

Try it yourself

import 'package:flutter/material.dart';
import 'package:get/get.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      home: PaginationDemo(),
    );
  }
}

// Mock API
Future<List<String>> fetchItems(int page, {int perPage = 10}) async {
  await Future.delayed(Duration(seconds: 1));
  if (page > 3) return []; // simulate no more data after page 3
  return List.generate(perPage, (i) => 'Item ${(page - 1) * perPage + i + 1}');
}

class PaginationController extends GetxController {
  var items = <String>[].obs;
  var currentPage = 1.obs;
  var hasMore = true.obs;
  var isLoading = false.obs;

  @override
  void onInit() {
    super.onInit();
    fetchItems();
  }

  Future<void> fetchItems() async {
    if (isLoading.value || !hasMore.value) return;
    isLoading.value = true;
    try {
      final newItems = await fetchItems(currentPage.value);
      if (newItems.isEmpty) {
        hasMore.value = false;
      } else {
        items.addAll(newItems);
        currentPage.value++;
      }
    } catch (e) {
      Get.snackbar('Error', e.toString());
    } finally {
      isLoading.value = false;
    }
  }

  void refreshItems() {
    items.clear();
    currentPage.value = 1;
    hasMore.value = true;
    fetchItems();
  }
}

class PaginationDemo extends StatelessWidget {
  final controller = Get.put(PaginationController());
  final ScrollController scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    scrollController.addListener(() {
      if (scrollController.position.pixels == scrollController.position.maxScrollExtent) {
        controller.fetchItems();
      }
    });
  }

  @override
  void dispose() {
    scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Pagination Demo'),
        actions: [
          IconButton(
            icon: Icon(Icons.refresh),
            onPressed: controller.refreshItems,
          ),
        ],
      ),
      body: RefreshIndicator(
        onRefresh: () async => controller.refreshItems(),
        child: Obx(() => ListView.builder(
          controller: scrollController,
          itemCount: controller.items.length + (controller.hasMore.value ? 1 : 0),
          itemBuilder: (_, index) {
            if (index == controller.items.length) {
              return Center(
                child: Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: CircularProgressIndicator(),
                ),
              );
            }
            return ListTile(title: Text(controller.items[index]));
          },
        )),
      ),
    );
  }
}

Test Your Knowledge

Q1
of 3

What condition should you check before triggering the next page load?

A
Whether the list is empty
B
Whether the user is at the top
C
Whether a load is already in progress and whether more data is available
D
Whether the app is in foreground
Q2
of 3

How do you reset pagination when the user pulls to refresh?

A
Clear the list and set page back to 1
B
Just call fetchItems again
C
Do nothing, it's automatic
D
Reset the scroll controller
Q3
of 3

What should you use to listen to the scroll position and detect when the end is reached?

A
ScrollNotification
B
NotificationListener
C
ScrollController.addListener
D
GestureDetector

Previous

getx authentication flow

Next

getx search filter

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.