Back to blog
Deep Dives
8 min readBuilding Infinite Scroll in Flutter: The Production Pattern
A complete infinite scroll implementation with pagination, loading states, error handling, and pull-to-refresh — the pattern used by production apps.
Building Infinite Scroll in Flutter
Every app with a feed needs infinite scroll. Here's the production pattern — with pagination, loading indicators, error recovery, and pull-to-refresh.
The Architecture
ScrollController → detects near-bottom
→ PaginationCubit.loadMore()
→ API call (page N+1)
→ Success: append items, increment page
→ Error: show retry, keep existing items
Step 1: The Pagination State
class PaginationState<T> {
final List<T> items;
final int page;
final bool hasMore;
final bool isLoading;
final String? error;
const PaginationState({
this.items = const [],
this.page = 1,
this.hasMore = true,
this.isLoading = false,
this.error,
});
PaginationState<T> copyWith({
List<T>? items,
int? page,
bool? hasMore,
bool? isLoading,
String? error,
}) {
return PaginationState(
items: items ?? this.items,
page: page ?? this.page,
hasMore: hasMore ?? this.hasMore,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}Step 2: The Cubit
class FeedCubit extends Cubit<PaginationState<Post>> {
final PostRepository _repo;
static const _pageSize = 20;
FeedCubit(this._repo) : super(const PaginationState());
Future<void> loadInitial() async {
emit(state.copyWith(isLoading: true, error: null));
try {
final posts = await _repo.getPosts(page: 1, limit: _pageSize);
emit(state.copyWith(
items: posts,
page: 1,
hasMore: posts.length == _pageSize,
isLoading: false,
));
} catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
Future<void> loadMore() async {
if (state.isLoading || !state.hasMore) return;
emit(state.copyWith(isLoading: true));
try {
final nextPage = state.page + 1;
final posts = await _repo.getPosts(page: nextPage, limit: _pageSize);
emit(state.copyWith(
items: [...state.items, ...posts],
page: nextPage,
hasMore: posts.length == _pageSize,
isLoading: false,
));
} catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
}Step 3: The Widget
class FeedScreen extends StatefulWidget {
@override
State<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends State<FeedScreen> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
context.read<FeedCubit>().loadInitial();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
context.read<FeedCubit>().loadMore();
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<FeedCubit, PaginationState<Post>>(
builder: (context, state) {
return RefreshIndicator(
onRefresh: () => context.read<FeedCubit>().loadInitial(),
child: ListView.builder(
controller: _scrollController,
itemCount: state.items.length + (state.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == state.items.length) {
if (state.error != null) {
return _RetryButton(
onTap: () => context.read<FeedCubit>().loadMore(),
);
}
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
);
}
return PostCard(post: state.items[index]);
},
),
);
},
);
}
}Key Details
- Threshold of 200px: Start loading before the user hits the bottom
- Guard against double-loads: Check
isLoadingandhasMorebefore requesting - Keep existing items on error: Never clear the list when page N+1 fails
- Pull-to-refresh resets to page 1: Fresh data from the top
InkPal Pattern
This exact pattern is available in InkPal's pattern database:
inkpal.patterns("infinite scroll pagination")
// Returns: complete BLoC + Riverpod implementations
Browse 11,000+ patterns at /patterns.