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.