Efficient API Data Handling in Flutter: Implementing Pagination with Provider

Benediktus Satriya
7 min readMar 26, 2024

--

Introduction

In modern app development, efficient data handling is paramount, especially when dealing with large datasets from APIs. Flutter, with its robust framework, provides various state management solutions to handle data effectively. Among them, Provider stands out for its simplicity and scalability. In this article, we’ll explore how to implement pagination using Provider in Flutter to manage API data efficiently.

Understanding Pagination

Pagination is a technique used to split large datasets into smaller, manageable chunks, thus enhancing performance and user experience. Instead of loading all data at once, pagination fetches data incrementally, typically in response to user interactions like scrolling or button clicks.

Setting Up the Project

Before diving into implementation, let’s set up a Flutter project and install necessary dependencies. Ensure you have Flutter SDK installed on your system.

flutter create pagination-simple-provider
cd pagination-simple-provider

Next, add the required dependencies in your pubspec.yaml file:

dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.6
provider: ^6.1.2
http: ^1.2.1

Run flutter pub get to install the dependencies.

Implementing Pagination with Provider

home_state.dart

Let’s begin by comprehending the state management setup for our pagination implementation. In the home_state.dart file, we define the HomeState class:

enum HomeStatus { initial, success, error }

class HomeState<T> {
final HomeStatus status;
final List<T> contacts;
final bool hasReachedMax;

const HomeState({
this.status = HomeStatus.initial,
this.contacts = const [],
this.hasReachedMax = false,
});

HomeState<T> copyWith({
HomeStatus? status,
List<T>? contacts,
bool? hasReachedMax,
}) {
return HomeState(
status: status ?? this.status,
contacts: contacts ?? this.contacts,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
);
}
}d

The HomeState class encapsulates the state of the home screen. It includes three main properties:

  • status: Represents the current status of data retrieval, as defined by the HomeStatus enum.
  • contacts: Stores the list of data items, such as contacts in this case. It's parameterized by type T, allowing flexibility in the type of data stored.
  • hasReachedMax: Indicates whether the maximum limit of data has been reached.

The constructor initializes these properties with default values, where status is set to initial, contacts is an empty list, and hasReachedMax is false by default. The copyWith method allows for creating a new instance of HomeState by optionally updating its properties.

home_page.dart

In Flutter, the HomePage class represents the main screen of an application. This class is responsible for displaying UI elements and handling user interactions.

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

@override
State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
final _scrollController = ScrollController();

@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}

@override
void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
super.dispose();
}

void _onScroll() {
if (_isBottom) {
context.read<HomeProvider>().getContacts();
}
}

bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.99);
}

@override
Widget build(BuildContext context) {
// UI implementation
}
}

The HomePage class is defined as a StatefulWidget and contains the logic for managing the state and building the UI of the home page. Here's a breakdown of key elements:

Scroll Controller:

  • An instance of ScrollController is created to manage scrolling behavior.
  • In the initState method, the scroll controller is initialized and set to listen for scroll events.
  • In the dispose method, the scroll controller is disposed of to release resources when the widget is removed from the widget tree.

Scroll Event Handling:

  • The _onScroll method is called whenever the user scrolls the page.
  • If the user has scrolled to the bottom of the page (_isBottom), the getContacts method of the HomeProvider is called to fetch more data.

Helper Method:

  • _isBottom is a helper method that checks if the user has scrolled to the bottom of the page.
return Scaffold(
// AppBar implementation
body: Consumer<HomeProvider>(
builder: (context, state, child) {
if (state.homeState.status == HomeStatus.initial) {
return const Center(child: CircularProgressIndicator());
}
if (state.homeState.status == HomeStatus.error) {
return const Center(child: Text("Failed to fetch posts"));
}
if (state.homeState.status == HomeStatus.success) {
if (state.homeState.contacts.isEmpty) {
return const Center(child: Text("No posts"));
}
return ListView.builder(
controller: _scrollController,
itemCount: state.homeState.hasReachedMax
? state.homeState.contacts.length
: state.homeState.contacts.length + 1,
itemBuilder: (context, index) {
return index >= state.homeState.contacts.length
? const Center(
child: CircularProgressIndicator(),
)
: Card(
child: ListTile(
leading: CircleAvatar(
child: Text(
state.homeState.contacts[index].id.toString()),
),
title: Text(state.homeState.contacts[index].title),
),
);
},
);
}
return const Center(child: CircularProgressIndicator());
},
),
);

Specifically, it utilizes the Consumer widget from the provider package to listen for changes in the HomeProvider state and rebuilds the UI accordingly.

Status Initial

  • Checks if the status of the HomeState within the HomeProvider is initial.
  • If the status is initial, it returns a CircularProgressIndicator widget centered on the screen, indicating that data is being fetched.

Status Errror

  • Checks if the status of the HomeState is error.
  • If the status is error, it returns a Text widget centered on the screen with the message "Failed to fetch posts," indicating that there was an error while fetching data.

Status Success

  • If the status is success and there are posts to display, it returns a ListView.builder widget.
  • The ListView.builder dynamically creates a list of widgets based on the length of the contacts list.
  • If the user scrolls to the end of the list (index >= state.homeState.contacts.length), it displays a CircularProgressIndicator widget at the center, indicating that more data is being fetched.
  • Otherwise, it constructs a Card widget for each post, displaying the post's ID and title using a CircleAvatar and Text widget respectively.

home_provider.dart



class HomeProvider with ChangeNotifier {
final _postLimit = 20;

var homeState = const HomeState<Post>();

Future<void> getContacts() async {
// implementation get contacts list
}

// fetching api
Future<List<Post>> _fetchPosts([int startIndex = 0]) async {
// implementation fetching api with http client
}
}
  • The HomeProvider class is defined as a ChangeNotifier, enabling it to notify its listeners (typically UI components) when its state changes.
  • It has a _postLimit constant representing the maximum number of posts to fetch.
  • The homeState variable holds the current state of the home screen. Initially, it's set to an empty state (HomeStatus.initial) with no contacts and not reaching the maximum limit.
Future<void> getContacts() async {
try {
if (homeState.status == HomeStatus.initial) {
final posts = await _fetchPosts();
homeState = homeState.copyWith(
status: HomeStatus.success,
contacts: posts,
hasReachedMax: posts.length < _postLimit,
);
} else {
final posts = await _fetchPosts(homeState.contacts.length);
homeState = homeState.copyWith(
status: HomeStatus.success,
contacts: List.of(homeState.contacts)..addAll(posts),
hasReachedMax: posts.length < _postLimit,
);
}

notifyListeners();
} catch (e) {
homeState = homeState.copyWith(
status: HomeStatus.error,
);
notifyListeners();
}
}
  • The getContacts method is responsible for fetching data. It's asynchronous, meaning it doesn't block the main UI thread while fetching data.
  • Inside the method, it checks the current status of homeState. If it's initial, it fetches initial data using _fetchPosts and updates homeState accordingly. If not, it fetches additional data based on the current list of contacts.
  • After updating homeState, it notifies its listeners about the state change using notifyListeners().
Future<List<Post>> _fetchPosts([int startIndex = 0]) async {
await Future.delayed(const Duration(seconds: 1));
final response = await client.get(
Uri.https(
'jsonplaceholder.typicode.com',
'/posts',
<String, String>{'_start': '$startIndex', '_limit': '$_postLimit'},
),
);
if (response.statusCode == 200) {
final body = json.decode(response.body) as List;
return body
.map((dynamic json) => Post.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('error fetching posts');
}
  • The _fetchPosts method is a private method responsible for making HTTP requests to fetch posts from the API.
  • If the response status code is 200 (indicating success), it decodes the response body from JSON to a list of posts using json.decode.
  • It then maps each JSON object to a Post object using Post.fromJson.

In summary, the HomeProvider class encapsulates the logic for fetching and managing data related to the home screen. It uses the ChangeNotifier pattern to notify its listeners (UI components) when its state changes, allowing for reactive UI updates.

main.dart

Before launching our application to ensure a smooth experience without any crashes or bugs, it’s crucial to set up our classes and providers within the main function.

return MaterialApp(
home: ChangeNotifierProvider(
create: (context) => HomeProvider()..getContacts(),
child: const HomePage(),
),
);

The create parameter of ChangeNotifierProvider is utilized to instantiate the HomeProvider class and immediately call its getContacts() method to fetch initial data. The create parameter expects a function that returns an instance of the provided class, in this case, HomeProvider.

Let’s now put our implementation to the test by trying out the app

Conclusion

By adopting pagination, developers can:

  1. Enhance Performance: By fetching and displaying data in smaller chunks, pagination reduces the load on the app and server, leading to faster response times and smoother user interactions.
  2. Improve User Experience: Breaking down data into manageable pages allows users to navigate through content more easily, preventing information overload and ensuring a more enjoyable browsing experience.
  3. Simplify Maintenance: Separating data retrieval and display logic into smaller, modular components makes code easier to understand, maintain, and debug, contributing to overall project scalability.

However, it’s essential to consider potential drawbacks:

  1. Complexity: Implementing pagination requires additional logic to manage page state and navigation, which may introduce complexity, especially in larger projects.
  2. Server-Side Requirements: Pagination relies on server-side support to retrieve data efficiently. Ensure that your backend API is capable of handling paginated requests.

In conclusion, while pagination with Provider offers numerous benefits for managing API data in Flutter applications, developers must weigh these advantages against potential complexities and backend requirements. By carefully considering the needs of your application and leveraging the flexibility of Flutter, you can create efficient and user-friendly experiences for your users. Happy coding!

Full example code https://github.com/benebobaa/pagination-simple-provider

--

--