Pagination
Efficiently loading and displaying large datasets β from infinite scrolling to page-based navigation.
Pagination is the process of dividing a large dataset into smaller chunks (pages) that are loaded incrementally. It's essential for:
- Performance β Loading all data at once can be slow and memory-intensive
- UX β Users don't want to wait for thousands of items to load
- Bandwidth β Reduces data usage by loading only what's needed
- Scalability β Works with APIs that have rate limits
π― The Problem Pagination Solves
Imagine an API that returns 10,000 users. Loading all of them at once would take several seconds, use lots of memory, and provide a poor user experience. Pagination solves this by loading 20 users at a time , with the option to load more when needed.
There are three main pagination strategies used in Flutter apps:
Different APIs use different pagination patterns. Here are the most common:
// Most common pattern
GET /api/users?page=2&limit=20
// Response includes pagination metadata
{
"data"
: [...],
"pagination"
: {
"currentPage"
: 2,
"totalPages"
: 10,
"totalItems"
: 200,
"itemsPerPage"
: 20
}
}
// Good for databases with offset support
GET /api/users?offset=20&limit=20
// Response (similar to page-based)
{
"data"
: [...],
"total"
: 200
}
// More efficient for large datasets
GET /api/users?after=abc123&limit=20
// Response includes cursor for next page
{
"data"
: [...],
"nextCursor"
: "def456",
"hasMore"
: true
}
Infinite scroll is the most common pagination pattern in mobile apps. Here's how to implement it
using a
ScrollController
and
ListView.builder
.
import
'dart:convert'
;
import
'package:http/http.dart'
as
http;
class
PaginationService
{
final
http.Client client;
static
const
int
pageSize =
20
;
PaginationService
({http.Client? client}) : client = client ?? http.Client();
Future
<Map<String, dynamic>>
fetchPage
(
int
page) async {
final
response = await client.get(
Uri.parse(
'https://jsonplaceholder.typicode.com/posts?_page=$page&_limit=$pageSize'
),
headers: {
'Accept'
:
'application/json'
},
);
if
(response.statusCode ==
200
) {
final
List<dynamic> data = jsonDecode(response.body);
// Note: JSONPlaceholder doesn't return pagination metadata
// In a real API, you'd parse the metadata from headers or body
return
{
'data'
: data,
'hasMore'
: data.length == pageSize,
// Simple heuristic
};
}
else
{
throw
Exception(
'Failed to load page $page'
);
}
}
}
import
'package:flutter/material.dart'
;
import
'pagination_service.dart'
;
class
InfiniteScrollScreen
extends
StatefulWidget {
@override
State<InfiniteScrollScreen>
createState
() => _InfiniteScrollScreenState();
}
class
_InfiniteScrollScreenState
extends
State<InfiniteScrollScreen> {
final
PaginationService _service = PaginationService();
final
ScrollController _scrollController = ScrollController();
List<dynamic> _items = [];
int
_currentPage =
1
;
bool
_isLoading =
false
;
bool
_hasMore =
true
;
bool
_hasError =
false
;
String? _errorMessage;
@override
void
initState
() {
super
.initState();
_loadMore();
_scrollController.addListener(_onScroll);
}
@override
void
dispose
() {
_scrollController.dispose();
super
.dispose();
}
void
_onScroll
() {
if
(_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent -
200
) {
_loadMore();
}
}
Future
<
void
>
_loadMore
() async {
if
(_isLoading || !_hasMore)
return
;
setState(() {
_isLoading =
true
;
_hasError =
false
;
_errorMessage =
null
;
});
try
{
final
result = await _service.fetchPage(_currentPage);
final
newItems = result[
'data'
]
as
List<dynamic>;
final
hasMore = result[
'hasMore'
]
as
bool
;
setState(() {
_items.addAll(newItems);
_hasMore = hasMore;
_currentPage++;
_isLoading =
false
;
});
}
catch
(e) {
setState(() {
_isLoading =
false
;
_hasError =
true
;
_errorMessage = e.toString();
});
}
}
void
_retry
() {
_loadMore();
}
@override
Widget
build
(BuildContext context) {
return
Scaffold(
appBar: AppBar(
title: Text(
'Infinite Scroll'
),
centerTitle:
true
,
),
body: Column(
children: [
Expanded(
child: _items.isEmpty && !_isLoading
? Center(
child: _hasError
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size:
64
, color: Colors.red),
SizedBox(height:
16
),
Text(_errorMessage ??
'Failed to load data'
),
SizedBox(height:
16
),
ElevatedButton(
onPressed: _retry,
child: Text(
'Retry'
),
),
],
)
: Text(
'No items to display'
),
)
: ListView.builder(
controller: _scrollController,
itemCount: _items.length + (_hasMore ?
1
:
0
),
itemBuilder: (context, index) {
// Show loading indicator at the end
if
(index == _items.length) {
return
Padding(
padding: EdgeInsets.symmetric(vertical:
16
),
child: Center(
child: CircularProgressIndicator(),
),
);
}
final
item = _items[index];
return
Card(
margin: EdgeInsets.symmetric(horizontal:
16
, vertical:
4
),
child: ListTile(
title: Text(item[
'title'
] ??
'No title'
),
subtitle: Text(item[
'body'
] ??
'No body'
),
leading: CircleAvatar(
child: Text((index +
1
).toString()),
),
),
);
},
),
),
// Error banner at bottom
if
(_hasError && _items.isNotEmpty)
Container(
padding: EdgeInsets.all(
12
),
color: Colors.red.shade50,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width:
8
),
Expanded(
child: Text(
_errorMessage ??
'Failed to load more items'
,
style: TextStyle(color: Colors.red.shade700),
),
),
TextButton(
onPressed: _retry,
child: Text(
'Retry'
),
),
],
),
),
],
),
);
}
}
page
and
limit
parameters.
_items
,
_currentPage
,
_isLoading
, and
_hasMore
.
Page-based pagination uses buttons to navigate between pages. This is common in web apps and some mobile apps.
import
'package:flutter/material.dart'
;
import
'pagination_service.dart'
;
class
PageBasedScreen
extends
StatefulWidget {
@override
State<PageBasedScreen>
createState
() => _PageBasedScreenState();
}
class
_PageBasedScreenState
extends
State<PageBasedScreen> {
final
PaginationService _service = PaginationService();
int
_currentPage =
1
;
int
_totalPages =
0
;
List<dynamic> _items = [];
bool
_isLoading =
false
;
bool
_hasError =
false
;
String? _errorMessage;
@override
void
initState
() {
super
.initState();
_loadPage(
1
);
}
Future
<
void
>
_loadPage
(
int
page) async {
setState(() {
_isLoading =
true
;
_hasError =
false
;
_errorMessage =
null
;
});
try
{
final
result = await _service.fetchPage(page);
final
data = result[
'data'
]
as
List<dynamic>;
final
hasMore = result[
'hasMore'
]
as
bool
;
setState(() {
_items = data;
_currentPage = page;
_isLoading =
false
;
// In a real API, you'd get totalPages from the response
_totalPages = hasMore ? page +
1
: page;
});
}
catch
(e) {
setState(() {
_isLoading =
false
;
_hasError =
true
;
_errorMessage = e.toString();
});
}
}
void
_goToPage
(
int
page) {
if
(page >=
1
&& page <= _totalPages) {
_loadPage(page);
}
}
@override
Widget
build
(BuildContext context) {
return
Scaffold(
appBar: AppBar(
title: Text(
'Page-Based Pagination'
),
centerTitle:
true
,
),
body: Column(
children: [
// Page navigation controls
Container(
padding: EdgeInsets.all(
16
),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(bottom: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: Icon(Icons.chevron_left),
onPressed: _currentPage >
1
&& !_isLoading
? () => _goToPage(_currentPage -
1
)
:
null
,
),
Text(
'Page $_currentPage'
,
style: TextStyle(fontWeight: FontWeight.bold),
),
IconButton(
icon: Icon(Icons.chevron_right),
onPressed: _currentPage < _totalPages && !_isLoading
? () => _goToPage(_currentPage +
1
)
:
null
,
),
SizedBox(width:
16
),
if (_totalPages >
0
)
Text(
'of $_totalPages'
,
style: TextStyle(color: Colors.grey),
),
],
),
),
// Content
Expanded(
child: _isLoading
? Center(child: CircularProgressIndicator())
: _hasError
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size:
64
, color: Colors.red),
SizedBox(height:
16
),
Text(_errorMessage ??
'Failed to load data'
),
SizedBox(height:
16
),
ElevatedButton(
onPressed: () => _loadPage(_currentPage),
child: Text(
'Retry'
),
),
],
),
)
: _items.isEmpty
? Center(child: Text(
'No items on this page'
))
: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
final
item = _items[index];
return
Card(
margin: EdgeInsets.symmetric(horizontal:
16
, vertical:
4
),
child: ListTile(
title: Text(item[
'title'
] ??
'No title'
),
subtitle: Text(item[
'body'
] ??
'No body'
),
),
);
},
),
),
],
),
);
}
}
The "Load More" button gives users explicit control over when to load more data. This is great for users with limited data plans or slow connections.
// Modify the infinite scroll screen to use a "Load More" button
// instead of automatic scrolling.
// Replace the ListView.builder itemCount with:
itemCount: _items.length +
1
,
// +1 for the load more button
// In the itemBuilder:
if
(index == _items.length) {
return
Container(
padding: EdgeInsets.symmetric(vertical:
16
),
child: Center(
child: _isLoading
? CircularProgressIndicator()
: _hasMore
? ElevatedButton(
onPressed: _loadMore,
child: Text(
'Load More'
),
)
: Text(
'No more items to load'
,
style: TextStyle(color: Colors.grey),
),
),
);
}
β Do's
- Always show a loading indicator when fetching more data
- Handle and display errors with retry options
- Use a reasonable page size (10-50 items per page)
- Cache data locally to reduce API calls
-
Use
ScrollControllerfor infinite scroll detection - Prevent duplicate requests with loading state
β Don'ts
- Don't fetch the same page multiple times
- Don't ignore the loading state (avoid duplicate requests)
- Don't make the page size too large (hurts performance)
- Don't forget to handle the "no more items" state
- Don't show loading indicators for empty pages
Without a loading state, users can trigger multiple requests by scrolling quickly or clicking "Load More" multiple times.
Track
_isLoading
and check it before making any new request.
If you don't indicate that there are no more items, users will keep trying to load more.
When
_hasMore
is false, show a message instead of a loading indicator.
If the page size is used in multiple files, changing it becomes tedious and error-prone.
Use a static constant like
static const int pageSize = 20;
in your service class.
π― Key Takeaway
Pagination is essential for handling large datasets efficiently. Infinite scroll provides the best UX for mobile apps. Always handle loading states , errors , and the "no more items" condition. Never let users trigger duplicate requests.