ghjfgj,.mbn
Back to blog
Deep Dives
8 min read

Building 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 isLoading and hasMore before 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.