Featured Image
Software Development

Streamlining paginated list fetching in Flutter using the generic BLoC pattern

When developing mobile apps with Flutter, it is common to need to fetch and display lists of data in a paginated way. To manage data flow and state in Flutter, many developers rely on the BLoC pattern. In this blog post, we’ll examine how to utilize a generic BLoC to fetch and display lists in Flutter. This approach offers advantages like code reusability, better maintainability, and cleaner architecture.

What is a BLoC?

Before diving into the specifics of using a generic BLoC, let’s briefly understand what it is. BLoC, or Business Logic Component, serves as a design pattern that effectively decouples the business logic from the UI layer in Flutter applications. BloC enables a continuous directional flow of data between applications as per a predefined framework. This adds to the remarkable attributes of BloC. It acts as a mediator between the UI and data layers, handling the business logic and state management.

The power of generics

Generics in programming allow us to create reusable components that can work with different types. By utilizing generics in our BLoC implementation, we can create a flexible and generic solution for handling list fetching.

Benefits of using generic BLoC for list fetching

Reusability: With a generic BLoC, you can easily reuse the same logic and implementation for fetching different types of lists in your application.

Maintainability: By encapsulating the list fetching logic in a separate BLoC class, you can easily maintain and update the codebase without affecting other application parts.

Testability: The generic BLoC can be easily tested in isolation, ensuring the correctness of your list fetching logic.

Scalability: As your application grows, you can extend the generic BLoC to handle more complex list-fetching scenarios without significant modifications to your existing code.

Also read: Enhancing Development Efficiency with Reusable Components

Implementing a generic BLoC for list fetching

Before beginning the implementation process, let’s add the necessary dependencies to the project’s pubspec.yaml file: 

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^8.1.3

Defining the generic BLoC class

We will create a generic BLoC class named FetchListBloc that can handle fetching lists of any type. This class will include methods for fetching data, managing the state, and handling events.

class FetchListBloc<T> extends Bloc<FetchListEvent, FetchListState<T>> {
  FetchListBloc({
    required this.fetchListItems,
    FetchListState<T>? initialState,
    Filter? filter,
  }) : super(initialState ?? FetchListState(filter: filter, items: [])) {
    on<FetchItems>((event, emit) async {});
  }

  final Future<FetchedList<T>> Function({
    required int page,
    Filter? filter,
  }) fetchListItems;
}

enum Status { loading, success, failure, empty }

abstract class Filter {
  const Filter();
}

class FetchedList<T> {
  const FetchedList({
    required this.nextPage,
    required this.hasNext,
    required this.items,
    this.filter,
  });

  const FetchedList.empty({
    this.filter,
    this.nextPage,
    this.hasNext = false,
    this.items = const [],
  });
  /// true if the list has items on the next page (should be provided in the API response)
  final bool hasNext;
  /// list of fetched items on the current page
  final List<T> items;
  /// page number to fetch in the next call (should be provided in the API response)
  final int? nextPage;
  /// filters used to fetch the list
  final Filter? filter;

  FetchedList<T> operator +(FetchedList<T> other) {
    return FetchedList(
      hasNext: other.hasNext,
      nextPage: other.nextPage,
      items: items + other.items,
    );
  }

  FetchedList<T> copyWith({
    Filter? filter,
    List<T>? items,
    bool? hasNext,
    int? nextPage,
  }) {
    return FetchedList(
      items: items ?? this.items,
      filter: filter ?? this.filter,
      hasNext: hasNext ?? this.hasNext,
      nextPage: nextPage ?? this.nextPage,
    );
  }
}

Let’s break down the FetchListBloc class:

  • T represents the type of data we want to fetch (e.g., User, Post, Product).
  • The fetchListItems method needs to be passed to each BLoC class instance to fetch data from the API. It takes the page number to be fetched in a paginated manner and the Filter needed for fetching the list. It returns the fetched list wrapped into the FetchedList<T> object.
  • The initialState is an optional field that is used in case we have some initial list available.
  • The Filter is an abstract class that can be implemented to contain all the filters needed to fetch the list.
  • The FetchedList is the wrapper class that holds the fetched list and other information about the pagination 

Creating events and states

Define events and states specific to your list fetching needs. We will have events like FetchItems, which extends the FetchListEvent and states like FetchListState<T>. Here we will be managing a single generic state class that will handle all the states like loading, success, failure, and empty. 

abstract class FetchListEvent extends Equatable {
  const FetchListEvent();

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

class FetchItems extends FetchListEvent {
  const FetchItems({this.refresh = false, this.filter});
  /// whether to fetch the first page of the list (i.e. fetch the list from the start)
  final bool refresh;
  /// the filters needed to fetch the list
  final Filter? filter;

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

class FetchListState<T> extends Equatable {
  const FetchListState({
    required this.items,
    this.error,
    this.filter,
    this.nextPage = 1,
    this.hasNext = false,
    this.status = Status.loading,
    this.paginationStatus = Status.empty,
  });
  /// true if the list has items on the next page (should be provided in the API response)
  final bool hasNext;
  /// page number to fetch in the next call (should be provided in the API response)
  final int nextPage;
  /// list of fetched items
  final List<T> items;
  /// API call status of the first page
  final Status status;
  /// holds the error for the latest API call if any
  final Object? error;
  /// filters used to fetch the list
  final Filter? Filter;
  /// API call status of the latest subsequent page
  final Status paginationStatus;

  String get errorMessage {
    if (error is Exception) return error.toString();
   
    return "Something went wrong";
  }

  FetchListState<T> copyWith({
    int? nextPage,
    bool? hasNext,
    Object? error,
    Status? status,
    List<T>? items,
    Filter? filter,
    Status? paginationStatus,
  }) {
    return FetchListState<T>(
      items: items ?? this.items,
      error: error ?? this.error,
      filter: filter ?? this.filter,
      status: status ?? this.status,
      hasNext: hasNext ?? this.hasNext,
      nextPage: nextPage ?? this.nextPage,
      paginationStatus: paginationStatus ?? this.paginationStatus,
    );
  }

  @override
  List<Object?> get props => [
        items,
        error,
        filter,
        status,
        hasNext,
        nextPage,
        paginationStatus,
      ];
}

Implementing BLoC logic

Inside the FetchListBloc class, implement the necessary logic to handle events, update the state, and fetch the list data.

class FetchListBloc<T> extends Bloc<FetchListEvent, FetchListState<T>> {
  FetchListBloc({
    required this.fetchListItems,
    FetchListState<T>? initialState,
    Filter? filter,
  }) : super(initialState ?? FetchListState(filter: filter, items: [])) {
    on<FetchItems>(
      (event, emit) async {
        final newFilter = event.filter ?? state.filter;
        final reset = event.refresh || event.filter != null;

        if (reset) {
          // update the filter to prevent any logical errors
          emit(
            state.copyWith(
              nextPage: 1,
              filter: newFilter,
              status: Status.loading,
            ),
          );
        }

        // update the state to loading
        if (state.status != Status.loading) {
          emit(state.copyWith(paginationStatus: Status.loading));
        }

        try {
          // fetch the list
          final fetchedList =
              await fetchListItems(page: state.nextPage, filter: newFilter);

          if (isClosed) return;

          // update the state as empty but don't make the existing list empty
          if (fetchedList.items.isEmpty) {
            emit(
              state.copyWith(
                status: Status.empty,
                paginationStatus: Status.empty,
                filter: fetchedList.filter ?? newFilter,
              ),
            );
            return;
          }

          // update the state with the list data
          emit(
            FetchListState(
              status: Status.success,
              hasNext: fetchedList.hasNext,
              nextPage: fetchedList.nextPage ?? 1,
              filter: fetchedList.filter ?? newFilter,
              items:
                  reset ? fetchedList.items : state.items + fetchedList.items,
            ),
          );
        } catch (error, stackTrace) {
          // update the state as error
          if (state.items.isEmpty || reset) {
            emit(state.copyWith(status: Status.failure, error: error));
          } else {
            emit(
              state.copyWith(paginationStatus: Status.failure, error: error),
            );
          }
        }
      },
    );

    // add the event as soon as the bloc is created
    add(const FetchItems());
  }

  final Future<FetchedList<T>> Function({
    required int page,
    Filter? filter,
  }) fetchListItems;
}

Integrating with the UI layer

To integrate the generic BLoC with the UI layer, we will create a generic widget called CustomBuilder which will handle the UI for all the states. Since it’s a generic widget, it can be used to manage not just FetchListBloc but any BLoC that shares a similar footprint as the FetchListEvent and FetchListState for its events and states respectively.

class CustomBuilder<B extends Bloc<E, S>, S, E>
    extends StatelessWidget {
  const CustomBuilder({
    super.key,
    required this.onRefresh,
    required this.successWidgetBuilder,
    this.loadingWidget,
    this.emptyWidget,
    this.errorWidget,
    this.buildWhen,
    this.listener,
  });
  /// widget builder for success state
  final Widget Function(BuildContext) successWidgetBuilder;
  /// callback for bloc event listener
  final void Function(BuildContext, S)? Listener;
  /// callback for the conditions to restrict when to rebuild the UI
  final BlocBuilderCondition<S>? buildWhen;
  /// callback for the refresh action
  final VoidCallback? onRefresh;
  final Widget? loadingWidget;
  final Widget? errorWidget;
  final Widget? emptyWidget;

  Widget builder(BuildContext context, S state) {
    late final Widget widget;
    // set all the default widgets for each state
    switch (state.status) {
      case Status.loading:
        widget = loadingWidget ?? DefaultLoadingWidget();
        break;
      case Status.success:
        widget = successWidgetBuilder(context);
        break;
      case Status.empty:
        widget = emptyWidget ?? DefaultEmptyWidget();
        break;
      case Status.failure:
        widget = errorWidget ?? DefaultErrorWidget();
        break;
    }

    return widget;
  }

  @override
  Widget build(BuildContext context) {
    if (listener != null) {
      return BlocConsumer<B, S>(
        buildWhen: buildWhen,
        listener: listener!,
        builder: builder,
      );
    }
    return BlocBuilder<B, S>(builder: builder, buildWhen: buildWhen);
  }
}

Let’s break down the CustomBuilder widget:

  • B represents the type of bloc that is currently being used to manage the UI.
  • S represents the type of the states of bloc B.
  • E represents the type of events of bloc B. 
  • Depending on the listener field, this widget will return either the BlocConsumer<B, S> or the BlocBuilder<B, S>.

Now we will create another generic widget called CustomListView as a wrapper to the CustomBuilder widget to show the paginated listview. Similarly, you can create more wrappers to the CustomBuilder widget to show different views like grid view, card view, etc.

typedef ItemWidgetBuilder<T> = Widget Function(
  BuildContext context,
  int index,
  T item,
);

class CustomListView<T> extends StatelessWidget {
  const CustomListView({
    super.key,
    required this.itemBuilder,
    this.paginationLoadingWidget,
    this.loadingWidget,
    this.errorWidget,
    this.emptyWidget,
    this.controller,
    this.buildWhen,
    this.listener,
  });

  final Widget? errorWidget;
  final Widget? emptyWidget;
  final Widget? loadingWidget;
  /// controller for the listview
  final ScrollController? Controller;
  /// loading widget to show when the next subsequent page is being fetched
  final Widget? paginationLoadingWidget;
  /// builder widget for the individual list item
  final ItemWidgetBuilder<T> itemBuilder;
  final BlocBuilderCondition<FetchListState<T>>? buildWhen;
  final void Function(BuildContext, FetchListState<T>)? listener;

  @override
  Widget build(BuildContext context) {
    return CustomBuilder<FetchListBloc<T>, FetchListState<T>,
        FetchListEvent>(
      listener: listener,
      buildWhen: buildWhen,
      emptyWidget: emptyWidget,
      errorWidget: errorWidget,
      loadingWidget: loadingWidget ?? DefaultLoadingWidget(),
      onRefresh: () => context
          .read<FetchListBloc<T>>()
          .add(const FetchItems(refresh: true)),
      successWidgetBuilder: (_) {
        // return your paginated list view
        return PaginatedListView<T>(
          controller: controller,
          itemBuilder: itemBuilder,
          paginationLoadingWidget: paginationLoadingWidget,
        );
      },
    );
  }
}

class PaginatedListView<T> extends StatefulWidget {
  const PaginatedListView({
    super.key,
    required this.itemBuilder,
    this.paginationLoadingWidget,
    this.controller,
  });

  final ScrollController? controller;
  final Widget? paginationLoadingWidget;
  final ItemWidgetBuilder<T> itemBuilder;

  @override
  State<PaginatedListView<T>> createState() => _PaginatedListViewState<T>();
}

class _PaginatedListViewState<T> extends State<PaginatedListView<T>> {
  late final FetchListBloc<T> fetchListBloc;
  late final ScrollController _controller;

  void _onScroll() {
    if (_controller.hasClients &&
        _controller.position.maxScrollExtent == _controller.offset &&
        fetchListBloc.state.hasNext &&
        fetchListBloc.state.paginationStatus != Status.loading &&
        // if an error occurs in pagination then stop further pagination calls
        fetchListBloc.state.error == null) {
      fetchListBloc.add(const FetchItems());
    }
  }

  @override
  void initState() {
    super.initState();
    fetchListBloc = context.read<FetchListBloc<T>>();
    _controller = widget.controller ?? ScrollController();
    _controller.addListener(_onScroll);
  }

  @override
  void dispose() {
    _controller
      ..removeListener(_onScroll)
      ..dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return BlocSelector<FetchListBloc<T>, FetchListState<T>, int>(
      bloc: fetchListBloc,
      selector: (state) => state.items.length,
      builder: (context, length) {
        return RefreshIndicator(
          onRefresh: () async =>
              fetchListBloc.add(const FetchItems(refresh: true)),
          child: ListView.builder(
            itemCount: length + 1,
            controller: _controller,
            physics: const AlwaysScrollableScrollPhysics(),
            itemBuilder: (context, index) {
              // show the pagination status indicators on the last index
              if (index == length) {
                return BlocSelector<FetchListBloc<T>, FetchListState<T>, Status>(
                  selector: (state) => state.paginationStatus,
                  builder: (context, paginationStatus) {
                    switch (paginationStatus) {
                      case Status.loading:
                        return paginationLoadingWidget ?? DefaultPaginationLoadingWidget();
                      case Status.failure:
                        return DefaultPaginationErrorWidget();
                      default:
                        return const SizedBox.shrink();
                    }
                  },
                );
              }
              // show the item widget
              return widget.itemBuilder(
                context,
                index,
                fetchListBloc.state.items[index],
              );
            },
          ),
        );
      },
    );
  }
}

Use the generic BLoC and generic widgets

Now that we have our generic BLoC class and generic widgets, we can use them to fetch and display the paginated list of a specific data type. For example, let’s fetch a list of User objects and display them in a paginated manner.

class UserFilter extends Filter {
  const UserFilter({this.premiumUsers = false});

  final bool premiumUsers;
}

BlocProvider(
  create: (context) => FetchListBloc<User>(
    // provide a method that calls the API and returns the users as FetchedList<User>
    fetchListItems: UserRepoImpl.instance.getUsers,
    // fetch only premium users
    filter: UserFilter(premiumUsers: true);
  ),
  child: CustomListView<User>(
    itemBuilder: (context, index, user) {
      // return your user widget
      return UserTile(user: user);
    },
  ),
)


Also read: Creating Real-Time 1-on-1 Chat in Flutter with CometChat SDK


Optimizing performance for list fetching efficiency

Optimization of performance is important when you want fast mobile apps, especially if you’re working with Flutter and implementing paginated list fetching using the generic BLoC Pattern. Here are several ways that will help you enhance your application’s speed and responsiveness:

Improve speed with caching

Implementation of caching mechanism is one of the best ways to optimize paginated list fetching. Caching can briefly store the previously fetched data. It also decreases the requirement for repetitive API calls. One can greatly improve the response rate and data consumption minimization by serving cached data especially when the user goes back to a previously visited list.

Boost performance with intelligent data prefetching

Prefetching data is an alternate clever way to boost performance.  This helps in delivering an even user experience by forecasting user communications and fetching data proactively before it’s even asked for. For example, when the user reaches the end of the current paginated list, you can trigger the prefetching of the next set of data in the background.

Integrating intelligent data prefetching with the generic BLoC pattern requires careful consideration of data consumption and user behavior. It’s a balance between improving responsiveness and optimizing resource usage.

Minimize unnecessary API calls

Making unnecessary API calls is a common pitfall in paginated list fetching. For instance, when the user repeatedly scrolls back and forth between pages, you may unintentionally trigger multiple API requests for the same data.

Implementation of techniques like debouncing or throttling can reduce redundant calls. Debouncing delays the API call until the user stops scrolling for a specific time period and throttling confines the number of API calls in the quantified time frame. These techniques confirm that you only draw data when it is extremely essential thereby saving network bandwidth and server resources.

Choose the right pagination granularity

Making the right choice for the right Pagination Granularity impacts performance significantly. However, it is crucial to generate a balance between the number of items that are fetched per page and the loading time of each page. It might take too long to load multiple items per page and fetching low items will reduce the load time and will result in frequent API calls, thus affecting overall performance.

Analysis of user behavior and usage patterns can help modify and refine the pagination granularity to deliver a fine experience.

Render efficiently

Efficient rendering of the paginated list is also critical for performance. You can use Flutter’s powerful ListView.builder or GridView.builder widgets to extract visible items on the screen so efficiently that off-screen items get postponed until any need arises. This technique will safeguard memory and also increases the speed of rendering process, especially for larger lists.

Choosing the Right Pagination Approach: Infinite Scroll vs. Load More Button

Infinite Scroll and Load More Button are two most commonly used navigation techniques for generating paginated list fetching in Flutter using the generic BLoC pattern. We’ll discover and find out the circumstances where each option is best suited for a continual user experience. We will also compare and differentiate these two techniques in this section.

Infinite scroll: 

Infinite scroll is a limitless prospect, where new data appears without obstruction as users browse through the list. This technique specifically removes any requirement for unambiguous user communication to load more data, thereby enabling an uninterrupted and immersive experience.

The BLoC logically detects the user’s scroll position and automatically triggers API calls to fetch more data as required while the execution of infinite scroll using the generic BLoC pattern is ongoing. This method allows us to update the list in real time without any additional effort.

Load more button:

Load more button has stark differences in comparison to Infinite Scroll. The Load More button acts as an inspiration as well as a supervising guide. Here, users are required to tap a designated button to fetch more data. This method gives users better control in deciding when to load additional content, which is exclusively useful for lists containing substantial data sets.

By including a button within the UI, the Load More Button can be implemented using the generic BloC pattern. When this button is pressed, it automatically prompts the BloC to draw out the next page of data.

User experience considerations

The choice between Infinite Scroll and Load More Button depends on various considerations based on a variety of user experience. Infinite Scroll is an ideal choice for lists with a limitless stream of data, for instance social media feed because it works nicely and efficiently for an unobstructed meaningful browsing experience for the users. On the contrary, Load More Button is more perfectly suited for situation where users require more control and need a guided approach to data loading in addition to providing a task conclusion after the data command work is done.

Performance and resource management

From a performance standpoint, Infinite Scroll may trigger more frequent API calls as the user scrolls through the list rapidly. This could lead to higher data consumption and put additional load on the server. Careful optimization and the use of throttling mechanisms are essential to ensure smooth performance.

Load More Button, however, puts the user in charge of data retrieval, potentially reducing the number of API calls. This approach offers more control over resource management, making it suitable for applications with limited data or those aiming to conserve network bandwidth.

Hybrid solutions

In many cases, a hybrid approach that combines both techniques might be the ideal solution. For example, the Infinite Scroll could be used initially to engage users and provide a smooth initial browsing experience. Once users reach a certain point or scroll depth, the Load More Button could take over to allow users more control over further data fetching.

Also read: Authentication and Navigation in Flutter Web with go_router

Best Practices and tips for mastering generic BLoC pattern for list fetching

When utilizing the generic BLoC pattern in Flutter for paginated list fetching, it is essential to have a deep understanding of the guidelines and best practices to keep in mind. In this section, you will find best practices, dos and don’ts, etc. that will prove helpful in generic BloC pattern implementation in real-world applications. 

Single responsibility principle 

The fundamental principle of SRP is to be held for creating a generic BloC class. Each BloC should maintain a focus on a specific domain or feature, adhering to the Single Responsibility Principle. This will guarantee a well-maintained code that will hold a certain level of clarity and can also be utilized again across different parts of your app.

Scalability with dependency injection

Dependency injection plays a very important role in scaling the app and implementing future updates. With a strong dependency injection framework like get_it or provider, you can manage the occurrences of generic BloC classes and inject them into various screens and widgets.

Emphasizing testability

Testability is the basic foundation of unfailing, result-oriented, and bug-free apps. Testability of the generic BloC class should be considered during design. This separation of business logic from the UI layer enables comprehensive unit testing and widget testing. This will ensure the accuracy of BloC’s behavior. 

Error handling with grace

Handling errors is extremely crucial for any real-world application. Therefore, implementing graceful error handling and informative error management capacity in the generic BloC class can help manage API failures and unforeseen events. Demonstrate suitable error messages to users so that they receive guidance while managing potential issues and the resulting disruptions during the user experience.

Also read: Choosing Between Dart and Kotlin: A Comprehensive Guide for Your App Development

Conclusion

Using a generic BLoC for list fetching in Flutter can greatly simplify your development process, improve code reusability, and enhance the overall architecture of your application. By separating the concerns of data fetching, state management, and UI rendering, you can build robust and maintainable Flutter applications.

In this blog, we created a generic BLoC class capable of fetching various types of data lists and a generic widget to display the fetched list in a paginated manner. With this approach, you can extend and reuse the generic BLoC class and the generic widgets to handle lists of various data types.

Hence, by utilizing the power of generics and implementing the BloC pattern one can create effective and scalable list-fetching solutions in the Flutter project.

For any support and assistance to embrace the BloC pattern in your Flutter app or any help with other Flutter application development related services, our team of experienced developers at Aubergine Solutions can help you. Connect with us today to know more.

author
Bhavik Dodia
I am a front-end mobile developer with a specialization in building mobile apps using Flutter. I have a knack for, and aspire to be better at building modern tech. I like solving problems, especially when there's details that some might say are easy to overlook.