Flutter Pagination Using BLoC & REST API

Flutter Pagination Using BLoC and REST API in Flutter (Complete Real-World Guide)



When I started building larger Flutter applications, one of the first major performance problems I faced was loading too much data at once.

Initially everything worked fine during development because the API only had a few records. But once the application started growing, the UI became laggy, memory usage increased, and scrolling performance started dropping badly.

That is where pagination became extremely important.

Almost every modern application uses pagination:

  • Instagram feed

  • Amazon products

  • Netflix movies

  • Twitter posts

  • LinkedIn jobs

  • YouTube videos

Without pagination, applications become slow very quickly.

In this article, we will build a proper production-style pagination system in Flutter using:

  • REST API

  • BLoC Architecture

  • Infinite Scrolling

  • Repository Pattern

  • Error Handling

  • Loading Indicators

  • Retry Logic

This is not just a demo implementation. I’ll also explain common mistakes developers make while implementing pagination in real-world Flutter apps.


What Exactly Is Pagination?

Pagination simply means loading data in smaller chunks instead of loading everything together.

For example:

Instead of loading:

  • 10,000 products

we load:

  • first 20 items

  • then next 20

  • then next 20 while scrolling

This improves:

  • performance

  • memory usage

  • API efficiency

  • user experience


Types of Pagination

Before implementing code, it’s important to understand the different pagination approaches.


1. Offset Pagination

This is the most common type.

Example API:

?page=1&limit=20

or

?offset=20&limit=20

This approach is easy to implement and works well for:

  • blogs

  • admin panels

  • small-to-medium applications

Advantages:

  • simple implementation

  • beginner-friendly

  • supported by most REST APIs

Disadvantages:

  • can become slower with huge datasets

  • duplicate records may occur during frequent updates


2. Cursor Pagination

Instead of page numbers, cursor pagination uses IDs or tokens.

Example:

?cursor=last_item_id

Used heavily in:

  • Instagram

  • Twitter

  • Facebook

Advantages:

  • faster for large-scale apps

  • better consistency

  • ideal for real-time systems

Disadvantages:

  • backend implementation becomes more complex


3. Infinite Scroll Pagination

This is the most popular UX approach today.

As users scroll:

  • new data loads automatically

Used in:

  • Instagram

  • TikTok

  • YouTube

  • Amazon

This is what we’ll implement in this article.


Why I Prefer BLoC for Pagination

I’ve implemented pagination using multiple approaches:

  • setState

  • Provider

  • Riverpod

  • BLoC

For larger applications, BLoC usually scales much better because:

  • API logic stays outside UI

  • state handling becomes predictable

  • loading/error states become cleaner

  • testing becomes easier

Especially when pagination gets complex.


Project Setup

Create a new Flutter project:

flutter create flutter_pagination_bloc

Add Required Packages

Inside pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter

  flutter_bloc: ^8.1.6
  dio: ^5.7.0
  equatable: ^2.0.5

Run:

flutter pub get

Recommended Folder Structure

One mistake many developers make is mixing UI and API logic together.

For scalable apps, keep things separated.

lib/
│
├── blocs/
│   └── products/
│       ├── products_bloc.dart
│       ├── products_event.dart
│       └── products_state.dart
│
├── models/
│   └── product_model.dart
│
├── repositories/
│   └── products_repository.dart
│
├── services/
│   └── api_service.dart
│
├── screens/
│   └── products_screen.dart
│
└── main.dart

This structure becomes extremely useful once the application grows.


Step 1 — Create Product Model

product_model.dart

class ProductModel {

  final int id;
  final String title;

  ProductModel({
    required this.id,
    required this.title,
  });

  factory ProductModel.fromJson(
      Map<String, dynamic> json) {

    return ProductModel(
      id: json['id'],
      title: json['title'],
    );
  }
}

Step 2 — Create API Service

I usually keep API-related logic inside a dedicated service layer.

api_service.dart

import 'package:dio/dio.dart';

class ApiService {

  final Dio dio = Dio();

  Future<Response> getProducts({
    required int page,
  }) async {

    return await dio.get(
      'https://jsonplaceholder.typicode.com/posts',
      queryParameters: {
        '_page': page,
        '_limit': 20,
      },
    );
  }
}

Here:

  • _page controls current page

  • _limit controls item count


Step 3 — Repository Layer

The repository acts as the middle layer between:

  • API

  • BLoC

This keeps architecture cleaner.

products_repository.dart

import '../models/product_model.dart';
import '../services/api_service.dart';

class ProductsRepository {

  final ApiService apiService;

  ProductsRepository(this.apiService);

  Future<List<ProductModel>> fetchProducts({
    required int page,
  }) async {

    final response =
        await apiService.getProducts(page: page);

    return (response.data as List)
        .map((e) => ProductModel.fromJson(e))
        .toList();
  }
}

Step 4 — Create Events

products_event.dart

part of 'products_bloc.dart';

abstract class ProductsEvent {}

class FetchProducts extends ProductsEvent {}

Step 5 — Create States

products_state.dart

part of 'products_bloc.dart';

abstract class ProductsState {}

class ProductsInitial extends ProductsState {}

class ProductsLoading extends ProductsState {}

class ProductsLoaded extends ProductsState {

  final List<ProductModel> products;
  final bool hasReachedMax;

  ProductsLoaded({
    required this.products,
    required this.hasReachedMax,
  });
}

class ProductsError extends ProductsState {

  final String message;

  ProductsError(this.message);
}

Step 6 — Create Pagination BLoC

This is the main logic.

products_bloc.dart

import 'package:flutter_bloc/flutter_bloc.dart';

import '../../models/product_model.dart';
import '../../repositories/products_repository.dart';

part 'products_event.dart';
part 'products_state.dart';

class ProductsBloc
    extends Bloc<ProductsEvent, ProductsState> {

  final ProductsRepository repository;

  int page = 1;

  bool isFetching = false;

  ProductsBloc(this.repository)
      : super(ProductsInitial()) {

    on<FetchProducts>((event, emit) async {

      if (isFetching) return;

      isFetching = true;

      try {

        List<ProductModel> oldProducts = [];

        if (state is ProductsLoaded) {
          oldProducts =
              (state as ProductsLoaded).products;
        }

        final newProducts =
            await repository.fetchProducts(
          page: page,
        );

        page++;

        final products = [
          ...oldProducts,
          ...newProducts,
        ];

        emit(
          ProductsLoaded(
            products: products,
            hasReachedMax:
                newProducts.isEmpty,
          ),
        );

      } catch (e) {

        emit(
          ProductsError(
            e.toString(),
          ),
        );
      }

      isFetching = false;
    });
  }
}

Why isFetching Is Important

During fast scrolling, multiple API calls can trigger together.

Without protection:

  • duplicate requests happen

  • UI flickers

  • pagination breaks

I faced this issue while building an eCommerce application with large product listings.

Adding:

bool isFetching = false;

solved the problem completely.


Step 7 — Create Pagination Screen

products_screen.dart

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

class ProductsScreen extends StatefulWidget {
  const ProductsScreen({super.key});

  @override
  State<ProductsScreen> createState() =>
      _ProductsScreenState();
}

class _ProductsScreenState
    extends State<ProductsScreen> {

  final ScrollController scrollController =
      ScrollController();

  @override
  void initState() {
    super.initState();

    context.read<ProductsBloc>()
        .add(FetchProducts());

    scrollController.addListener(() {

      if (scrollController.position.pixels >=
          scrollController
                  .position.maxScrollExtent -
              200) {

        context.read<ProductsBloc>()
            .add(FetchProducts());
      }
    });
  }

  @override
  Widget build(BuildContext context) {

    return Scaffold(
      appBar: AppBar(
        title: const Text("Pagination"),
      ),

      body:
          BlocBuilder<ProductsBloc, ProductsState>(
        builder: (context, state) {

          if (state is ProductsLoading) {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }

          if (state is ProductsError) {
            return Center(
              child: Text(state.message),
            );
          }

          if (state is ProductsLoaded) {

            return ListView.builder(
              controller: scrollController,
              itemCount: state.products.length +
                  (state.hasReachedMax ? 0 : 1),

              itemBuilder: (context, index) {

                if (index >=
                    state.products.length) {

                  return const Padding(
                    padding: EdgeInsets.all(20),
                    child: Center(
                      child:
                          CircularProgressIndicator(),
                    ),
                  );
                }

                final product =
                    state.products[index];

                return ListTile(
                  title: Text(product.title),
                );
              },
            );
          }

          return const SizedBox();
        },
      ),
    );
  }
}

Step 8 — Initialize Everything

main.dart

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

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {

    return MaterialApp(
      debugShowCheckedModeBanner: false,

      home: BlocProvider(
        create: (_) => ProductsBloc(
          ProductsRepository(
            ApiService(),
          ),
        ),
        child: const ProductsScreen(),
      ),
    );
  }
}

Common Pagination Mistakes

These are issues I’ve personally faced while implementing pagination in production apps.


1. Replacing Old Data

Wrong:

emit(ProductsLoaded(products: newProducts));

This removes previously loaded items.

Correct:

final products = [
  ...oldProducts,
  ...newProducts,
];

2. API Calls Triggering Multiple Times

This usually happens because:

  • scroll listener fires repeatedly

Solution:

  • use loading guards

  • use debounce logic


3. Loading Too Much Data

Never fetch:

  • huge payloads

  • unnecessary fields

Only load what’s needed.


Performance Tips

Use const Widgets

This reduces rebuilds significantly.


Avoid Heavy Widgets Inside Lists

Large widget trees inside ListView.builder
can hurt scrolling performance.


Use Cached Images

For real apps:

cached_network_image

improves performance a lot.


Real-World Improvements

Production apps usually also include:

  • pull-to-refresh

  • shimmer loading

  • offline caching

  • retry mechanism

  • search + pagination

  • filtering

  • local cache synchronization


Final Thoughts

Pagination is one of those features that looks simple initially but becomes surprisingly complex in real-world applications.

A proper pagination system should:

  • avoid duplicate API calls

  • maintain smooth scrolling

  • handle errors gracefully

  • scale efficiently

Using:

  • BLoC

  • Repository Pattern

  • Infinite Scrolling

creates a much cleaner and scalable architecture for Flutter applications.


FAQs

Which pagination type is best?

For most modern apps:

  • infinite scroll pagination

provides the best user experience.


Why use BLoC for pagination?

Because pagination involves:

  • loading states

  • error states

  • API synchronization

  • list merging

BLoC handles these scenarios very well.


Can pagination work offline?

Yes.

You can combine:

  • pagination

  • local database caching

  • repository pattern

for offline-first architecture.


Conclusion

In this guide, we implemented:

  • Flutter pagination

  • Infinite scrolling

  • REST API integration

  • BLoC architecture

  • Repository pattern

  • Loading states

  • Error handling

This structure can now be expanded into:

  • eCommerce applications

  • social media apps

  • enterprise dashboards

  • inventory systems

  • large-scale Flutter applications

Comments