flutter
/

BLoC Lazy Loading: Implementing Pagination & Infinite Scroll

Last Sync: Today

On this page

12
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC Lazy Loading: Implementing Pagination & Infinite Scroll

What is Lazy Loading with BLoC?

Lazy loading in BLoC refers to loading data incrementally as the user scrolls through a list, rather than loading all data at once. This pattern, often called pagination or infinite scroll, improves performance, reduces initial load times, and saves bandwidth. The BLoC pattern helps manage the complex state of loading, error, and pagination metadata (page number, offset, hasMore) in a clean and testable way.

Why Use Lazy Loading?

  • Performance – Load only what the user needs, reducing memory usage.
  • Faster Initial Load – Display first page quickly, then load more on demand.
  • Bandwidth Efficiency – Fetch data in chunks, ideal for mobile networks.
  • Better UX – Smooth infinite scrolling without waiting for all data.
  • Scalability – Handle large datasets without overwhelming the UI.

Core Concepts

  • Pagination Metadata – Tracks current page, offset, limit, and whether more data exists.
  • Loading States – Distinguish between initial loading and loading more.
  • Error Handling – Allow retry for failed page loads.
  • Throttling – Prevent duplicate requests when scrolling triggers multiple events.

Setting Up Dependencies

Add the required packages to pubspec.yaml:

YAMLRead-only
1
dependencies:
  flutter_bloc: ^8.1.5
  equatable: ^2.0.5
  rxdart: ^0.28.0
  dio: ^5.4.0

Defining State and Events

Create a state that holds the list of items, pagination metadata, and loading/error flags. Events will trigger initial load and subsequent page loads.

DARTRead-only
1
// State
class PostState extends Equatable {
  final List<Post> posts;
  final bool hasReachedMax;
  final int page;
  final PostStatus status;
  final String? error;

  const PostState({
    this.posts = const [],
    this.hasReachedMax = false,
    this.page = 1,
    this.status = PostStatus.initial,
    this.error,
  });

  PostState copyWith({
    List<Post>? posts,
    bool? hasReachedMax,
    int? page,
    PostStatus? status,
    String? error,
  }) {
    return PostState(
      posts: posts ?? this.posts,
      hasReachedMax: hasReachedMax ?? this.hasReachedMax,
      page: page ?? this.page,
      status: status ?? this.status,
      error: error ?? this.error,
    );
  }

  @override
  List<Object?> get props => [posts, hasReachedMax, page, status, error];
}

enum PostStatus { initial, loading, success, failure, loadingMore }

// Events
abstract class PostEvent extends Equatable {
  const PostEvent();

  @override
  List<Object?> get props => [];
}

class FetchPosts extends PostEvent {
  final bool isRefresh;
  const FetchPosts({this.isRefresh = false});

  @override
  List<Object?> get props => [isRefresh];
}

class FetchMorePosts extends PostEvent {}

Implementing the BLoC with Throttling

Use a transformer to throttle the FetchMorePosts events. This prevents multiple rapid requests when the user scrolls fast. Also handle initial load and pagination logic.

DARTRead-only
1
class PostBloc extends Bloc<PostEvent, PostState> {
  final PostRepository repository;

  PostBloc({required this.repository}) : super(const PostState()) {
    on<FetchPosts>(
      _onFetchPosts,
      transformer: throttle(), // Optional: debounce if needed
    );
    on<FetchMorePosts>(
      _onFetchMorePosts,
      transformer: throttle(),
    );
  }

  Future<void> _onFetchPosts(FetchPosts event, Emitter<PostState> emit) async {
    if (event.isRefresh) {
      emit(state.copyWith(
        status: PostStatus.loading,
        posts: [],
        page: 1,
        hasReachedMax: false,
      ));
    } else if (state.status == PostStatus.initial) {
      emit(state.copyWith(status: PostStatus.loading));
    }

    try {
      final newPosts = await repository.fetchPosts(page: 1, limit: 20);
      final hasReachedMax = newPosts.length < 20;
      emit(state.copyWith(
        posts: newPosts,
        hasReachedMax: hasReachedMax,
        page: 2,
        status: PostStatus.success,
        error: null,
      ));
    } catch (e) {
      emit(state.copyWith(
        status: PostStatus.failure,
        error: e.toString(),
      ));
    }
  }

  Future<void> _onFetchMorePosts(FetchMorePosts event, Emitter<PostState> emit) async {
    if (state.hasReachedMax || state.status == PostStatus.loadingMore) return;

    emit(state.copyWith(status: PostStatus.loadingMore));
    try {
      final newPosts = await repository.fetchPosts(
        page: state.page,
        limit: 20,
      );
      final hasReachedMax = newPosts.length < 20;
      emit(state.copyWith(
        posts: List.of(state.posts)..addAll(newPosts),
        hasReachedMax: hasReachedMax,
        page: state.page + 1,
        status: PostStatus.success,
        error: null,
      ));
    } catch (e) {
      emit(state.copyWith(
        status: PostStatus.failure,
        error: e.toString(),
      ));
    }
  }
}

// Throttle transformer using RxDart
EventTransformer<Event> throttle<Event>({Duration duration = const Duration(milliseconds: 500)}) {
  return (events, mapper) => events.throttleTime(duration).flatMap(mapper);
}

Repository with Pagination

The repository abstracts the data source. It can fetch from an API, database, or both.

DARTRead-only
1
class PostRepository {
  final Dio dio;

  PostRepository(this.dio);

  Future<List<Post>> fetchPosts({required int page, required int limit}) async {
    final response = await dio.get('/posts', queryParameters: {
      '_page': page,
      '_limit': limit,
    });
    final List data = response.data;
    return data.map((json) => Post.fromJson(json)).toList();
  }
}

class Post {
  final int id;
  final String title;
  final String body;

  Post({required this.id, required this.title, required this.body});

  factory Post.fromJson(Map<String, dynamic> json) {
    return Post(
      id: json['id'],
      title: json['title'],
      body: json['body'],
    );
  }
}

UI with Infinite Scroll

Use ListView.builder with a ScrollController to detect when the user reaches the end. Dispatch FetchMorePosts when near the bottom.

DARTRead-only
1
class PostsPage extends StatefulWidget {
  @override
  _PostsPageState createState() => _PostsPageState();
}

class _PostsPageState extends State<PostsPage> {
  final ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_onScroll);
    context.read<PostBloc>().add(FetchPosts());
  }

  void _onScroll() {
    if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
      context.read<PostBloc>().add(FetchMorePosts());
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Posts')),
      body: BlocBuilder<PostBloc, PostState>(
        builder: (context, state) {
          switch (state.status) {
            case PostStatus.failure:
              return Center(child: Text('Error: ${state.error}'));
            case PostStatus.success:
              if (state.posts.isEmpty) {
                return Center(child: Text('No posts'));
              }
              return ListView.builder(
                controller: _scrollController,
                itemCount: state.hasReachedMax ? state.posts.length : state.posts.length + 1,
                itemBuilder: (_, index) {
                  if (index >= state.posts.length) {
                    return Center(child: CircularProgressIndicator());
                  }
                  return ListTile(
                    title: Text(state.posts[index].title),
                    subtitle: Text(state.posts[index].body),
                  );
                },
              );
            default:
              return Center(child: CircularProgressIndicator());
          }
        },
      ),
    );
  }
}

Advanced: Cursor-Based Pagination

Some APIs use cursor-based pagination (e.g., GraphQL, REST with next token). Adapt the state to hold a nextCursor instead of a page number.

DARTRead-only
1
class CursorState extends Equatable {
  final List<Item> items;
  final String? nextCursor;
  final bool hasMore;
  final CursorStatus status;

  const CursorState({this.items = const [], this.nextCursor, this.hasMore = true, this.status = CursorStatus.initial});

  // ... copyWith, props, etc.
}

class FetchMoreCursor extends PostEvent {}

// In BLoC
Future<void> _onFetchMoreCursor(FetchMoreCursor event, Emitter<CursorState> emit) async {
  if (!state.hasMore || state.status == CursorStatus.loadingMore) return;
  emit(state.copyWith(status: CursorStatus.loadingMore));
  try {
    final result = await repository.fetchItems(cursor: state.nextCursor);
    emit(state.copyWith(
      items: List.of(state.items)..addAll(result.items),
      nextCursor: result.nextCursor,
      hasMore: result.nextCursor != null,
      status: CursorStatus.success,
    ));
  } catch (e) {
    emit(state.copyWith(status: CursorStatus.failure, error: e.toString()));
  }
}

Best Practices

  • Use throttling – Prevent duplicate FetchMore events when the user scrolls quickly.
  • Separate initial load from load more – Use different events or a flag to differentiate states.
  • Show loading indicators – Provide visual feedback when loading more (e.g., a progress indicator at the bottom).
  • Handle errors gracefully – Allow retry by dispatching the event again or showing a retry button.
  • Cache pages locally – Consider storing fetched data in a local database to avoid re-fetching.
  • Use Equatable for states – Prevent unnecessary rebuilds.
  • Avoid duplicate requests – Check hasReachedMax and status before triggering new loads.
  • Dispose controllers – Always dispose ScrollController in State.dispose.

Common Mistakes

  • ❌ Not checking if already loading – Leads to multiple simultaneous requests.
  • ❌ Forgetting to reset pagination on refresh – After refresh, reset page and hasReachedMax.
  • ❌ Using BlocBuilder without optimization – Rebuilds the entire list on each state change, causing flicker. Use BlocSelector for parts that change.
  • ❌ Not handling empty or hasReachedMax cases – The UI may keep trying to load more.
  • ❌ Blocking the UI with heavy computations – Offload parsing to isolates if needed.
  • ❌ Not using const for item widgets – Improves performance for long lists.

Conclusion

Lazy loading with BLoC is a robust solution for handling paginated data. By separating concerns into events, states, and repositories, you can build performant infinite scroll lists with clear loading and error states. Remember to throttle events, manage pagination metadata carefully, and optimize UI rebuilds. This pattern scales well from simple lists to complex data grids.

Try it yourself

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:rxdart/rxdart.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'BLoC Lazy Loading Demo',
      home: BlocProvider(
        create: (_) => PostBloc(repository: MockPostRepository()),
        child: PostsPage(),
      ),
    );
  }
}

// ---------- Models ----------
class Post {
  final int id;
  final String title;
  final String body;

  Post({required this.id, required this.title, required this.body});
}

// ---------- Repository ----------
class MockPostRepository {
  Future<List<Post>> fetchPosts({required int page, required int limit}) async {
    await Future.delayed(Duration(milliseconds: 500));
    // Simulate paginated data
    if (page > 3) return []; // No more data after page 3
    final startId = (page - 1) * limit + 1;
    final endId = startId + limit - 1;
    return List.generate(
      limit,
      (index) => Post(
        id: startId + index,
        title: 'Post ${startId + index}',
        body: 'This is the body of post ${startId + index}.',
      ),
    );
  }
}

// ---------- State ----------
enum PostStatus { initial, loading, success, failure, loadingMore }

class PostState extends Equatable {
  final List<Post> posts;
  final bool hasReachedMax;
  final int page;
  final PostStatus status;
  final String? error;

  const PostState({
    this.posts = const [],
    this.hasReachedMax = false,
    this.page = 1,
    this.status = PostStatus.initial,
    this.error,
  });

  PostState copyWith({
    List<Post>? posts,
    bool? hasReachedMax,
    int? page,
    PostStatus? status,
    String? error,
  }) {
    return PostState(
      posts: posts ?? this.posts,
      hasReachedMax: hasReachedMax ?? this.hasReachedMax,
      page: page ?? this.page,
      status: status ?? this.status,
      error: error ?? this.error,
    );
  }

  @override
  List<Object?> get props => [posts, hasReachedMax, page, status, error];
}

// ---------- Events ----------
abstract class PostEvent extends Equatable {
  const PostEvent();
  @override
  List<Object?> get props => [];
}

class FetchPosts extends PostEvent {
  final bool isRefresh;
  const FetchPosts({this.isRefresh = false});
  @override
  List<Object?> get props => [isRefresh];
}

class FetchMorePosts extends PostEvent {}

// ---------- BLoC ----------
EventTransformer<Event> throttle<Event>({Duration duration = const Duration(milliseconds: 500)}) {
  return (events, mapper) => events.throttleTime(duration).flatMap(mapper);
}

class PostBloc extends Bloc<PostEvent, PostState> {
  final MockPostRepository repository;

  PostBloc({required this.repository}) : super(const PostState()) {
    on<FetchPosts>(_onFetchPosts);
    on<FetchMorePosts>(_onFetchMorePosts, transformer: throttle());
  }

  Future<void> _onFetchPosts(FetchPosts event, Emitter<PostState> emit) async {
    if (event.isRefresh) {
      emit(state.copyWith(
        status: PostStatus.loading,
        posts: [],
        page: 1,
        hasReachedMax: false,
      ));
    } else if (state.status == PostStatus.initial) {
      emit(state.copyWith(status: PostStatus.loading));
    }

    try {
      final newPosts = await repository.fetchPosts(page: 1, limit: 20);
      final hasReachedMax = newPosts.length < 20;
      emit(state.copyWith(
        posts: newPosts,
        hasReachedMax: hasReachedMax,
        page: 2,
        status: PostStatus.success,
        error: null,
      ));
    } catch (e) {
      emit(state.copyWith(
        status: PostStatus.failure,
        error: e.toString(),
      ));
    }
  }

  Future<void> _onFetchMorePosts(FetchMorePosts event, Emitter<PostState> emit) async {
    if (state.hasReachedMax || state.status == PostStatus.loadingMore) return;

    emit(state.copyWith(status: PostStatus.loadingMore));
    try {
      final newPosts = await repository.fetchPosts(
        page: state.page,
        limit: 20,
      );
      final hasReachedMax = newPosts.length < 20;
      emit(state.copyWith(
        posts: List.of(state.posts)..addAll(newPosts),
        hasReachedMax: hasReachedMax,
        page: state.page + 1,
        status: PostStatus.success,
        error: null,
      ));
    } catch (e) {
      emit(state.copyWith(
        status: PostStatus.failure,
        error: e.toString(),
      ));
    }
  }
}

// ---------- UI ----------
class PostsPage extends StatefulWidget {
  @override
  _PostsPageState createState() => _PostsPageState();
}

class _PostsPageState extends State<PostsPage> {
  final ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_onScroll);
    context.read<PostBloc>().add(FetchPosts());
  }

  void _onScroll() {
    if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
      context.read<PostBloc>().add(FetchMorePosts());
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Posts (Infinite Scroll)')),
      body: BlocBuilder<PostBloc, PostState>(
        builder: (context, state) {
          switch (state.status) {
            case PostStatus.failure:
              return Center(child: Text('Error: ${state.error}'));
            case PostStatus.success:
              if (state.posts.isEmpty) {
                return Center(child: Text('No posts'));
              }
              return ListView.builder(
                controller: _scrollController,
                itemCount: state.hasReachedMax ? state.posts.length : state.posts.length + 1,
                itemBuilder: (_, index) {
                  if (index >= state.posts.length) {
                    return Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: Center(child: CircularProgressIndicator()),
                    );
                  }
                  return ListTile(
                    title: Text(state.posts[index].title),
                    subtitle: Text(state.posts[index].body),
                  );
                },
              );
            default:
              return Center(child: CircularProgressIndicator());
          }
        },
      ),
    );
  }
}

Test Your Knowledge

Q1
of 4

What is the primary purpose of lazy loading in a Flutter app?

A
To load all data at once
B
To load data incrementally as needed
C
To reduce code complexity
D
To improve animations
Q2
of 4

Which transformer is commonly used to prevent duplicate `FetchMorePosts` events during fast scrolling?

A
debounce
B
throttle
C
sequential
D
concurrent
Q3
of 4

What should you check before dispatching a `FetchMorePosts` event to avoid duplicate requests?

A
`state.hasReachedMax` and `state.status != PostStatus.loadingMore`
B
`state.posts.isEmpty`
C
`state.page > 1`
D
The scroll position
Q4
of 4

How do you reset pagination when the user performs a pull-to-refresh?

A
Clear the list and set page back to 1, then fetch new data
B
Only fetch new data and append
C
Dispose the bloc and recreate
D
Ignore the refresh

Frequently Asked Questions

How do I reset the list when the user performs a pull-to-refresh?

Dispatch a FetchPosts event with isRefresh: true. In the event handler, reset the state (clear posts, reset page to 1, hasReachedMax = false) and fetch fresh data. Then merge with existing logic.

What if the API returns the total count?

You can store totalCount in the state and compute hasReachedMax based on whether the fetched items length equals the limit and the total items are less than or equal to the current count.

How can I implement optimistic updates with pagination?

For adding an item, you can update the state directly (add to the list) and then send a request. On failure, roll back. For paginated lists, you might need to adjust the total count and possibly reload.

Should I use `StreamBuilder` with BLoC?

BlocBuilder is preferred. It works directly with the bloc's state stream. No need to manually listen to streams.

How to test lazy loading BLoC?

Use blocTest. Simulate FetchPosts and FetchMorePosts events, verify state transitions, and ensure the hasReachedMax flag is set correctly.

Previous

bloc rebuild optimization

Next

bloc hydrated bloc

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.