DEV Community

Cover image for Building Scalable Flutter Apps with Cubit Abstraction: A Practical Guide
Yusuf Umar Hanafi
Yusuf Umar Hanafi

Posted on

Building Scalable Flutter Apps with Cubit Abstraction: A Practical Guide

Introduction

Flutter has become one of the most popular frameworks for cross-platform development, and state management is a crucial aspect of any Flutter application. Among various state management solutions, BLoC (Business Logic Component) pattern has gained significant traction. The Cubit pattern, a simplified version of BLoC, provides an elegant way to manage state without the complexity of events.

In this article, I'll guide you through creating a reusable, abstract Cubit that can significantly reduce boilerplate code while maintaining clean architecture principles. This approach is especially useful for handling listed data that follows the common pattern of loading, success, error, and empty states.

Programming Paradigms at Play

Before diving into implementation details, let's understand the programming paradigms used in our Cubit abstraction:

  1. Object-Oriented Programming (OOP): We use abstract classes and inheritance to create reusable components.
  2. Reactive Programming: The Cubit pattern follows reactive principles, emitting states that UI components can react to.
  3. Functional Programming: We leverage functional concepts like immutable states and the Either type for error handling.
  4. Generic Programming: Our base Cubit is generic (LoadListedDataCubit<T>), making it reusable for different data types.

Package Installation

Let's start by adding the necessary packages to your pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^8.1.3  # For Cubit implementation
  dartz: ^0.10.1        # For functional programming constructs like Either
  freezed: ^2.4.2       # For immutable state classes
  freezed_annotation: ^2.4.1
  injectable: ^2.1.2    # For dependency injection

dev_dependencies:
  build_runner: ^2.4.6
  freezed_generator: ^2.4.2
  injectable_generator: ^2.1.6
Enter fullscreen mode Exit fullscreen mode

Run the following command to install these dependencies:

flutter pub get
Enter fullscreen mode Exit fullscreen mode

Creating the Abstract Load Listed Data Cubit

The core of our abstraction is the LoadListedDataCubit, which handles the common patterns for loading listed data:

import 'package:dartz/dartz.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

import '../../core.dart';

part 'load_listed_data_state.dart';
part 'load_listed_data_cubit.freezed.dart';

abstract class LoadListedDataCubit<T> extends Cubit<LoadListedDataState<T>> {
  LoadListedDataCubit() : super(const LoadListedDataState.initial()) {
    _initial();
  }

  void _initial() async {
    await getData();
  }

  Future getData() async {
    emit(const LoadListedDataState.loading());
    final Either<AppException, List<T>> result = await fetchData();
    result.fold(
      (error) => emit(LoadListedDataState.error(error: error)),
      (itemList) {
        if (itemList.isEmpty) {
          emit(const LoadListedDataState.empty());
        } else {
          emit(LoadListedDataState.success(data: itemList));
        }
      }
    );
  }

  Future<Either<AppException, List<T>>> fetchData();
}
Enter fullscreen mode Exit fullscreen mode

Now let's define the state class:

// filepath: lib/core/cubit/load_listed_data/load_listed_data_state.dart
part of 'load_listed_data_cubit.dart';

@freezed
class LoadListedDataState<T> with _$LoadListedDataState<T> {
  const factory LoadListedDataState.initial() = _Initial;
  const factory LoadListedDataState.loading() = _Loading;
  const factory LoadListedDataState.success({required List<T> data}) = _Success;
  const factory LoadListedDataState.error({required AppException error}) = _Error;
  const factory LoadListedDataState.empty() = _Empty;
}
Enter fullscreen mode Exit fullscreen mode

After defining these files, run code generation:

flutter pub run build_runner build --delete-conflicting-outputs
Enter fullscreen mode Exit fullscreen mode

Understanding the Abstraction

Let's break down what's happening in our LoadListedDataCubit:

  1. Generic Type: <T> allows us to reuse this Cubit for any data type.
  2. Initial State: The constructor sets up an initial state and triggers data loading.
  3. State Management: The Cubit handles five states (initial, loading, success, error, and empty).
  4. Template Method Pattern: The fetchData() method is abstract, while getData() provides the common implementation.
  5. Error Handling: We use the Either type from dartz to handle success and error cases elegantly.

Implementing a Concrete Cubit

Now, let's implement a concrete Cubit for a specific feature, like loading tweets:

import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';

import '../../../../core/core.dart';
import '../../data/models/tweet_response_model.dart';
import '../../domain/usecases/get_tweet_usecase.dart';

@lazySingleton
class TweetCubit extends LoadListedDataCubit<TweetResponseModel> {
  TweetCubit({required GetTweetUsecase getTweetUsecase})
      : _getTweetUsecase = getTweetUsecase;

  final GetTweetUsecase _getTweetUsecase;

  @override
  Future<Either<AppException, List<TweetResponseModel>>> fetchData() async {
    return await _getTweetUsecase();
  }
}
Enter fullscreen mode Exit fullscreen mode

That's it! Notice how little code we needed to write for our TweetCubit. All the common loading, error handling, and state management logic is inherited from the base class.

Using the Cubit in UI

To use our Cubit in the UI, we can do something like this:

// filepath: lib/modules/tweet/presentation/pages/tweet_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../cubit/tweet_cubit.dart';
import '../../../../core/core.dart';
import '../../data/models/tweet_response_model.dart';

class TweetPage extends StatelessWidget {
  const TweetPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => getIt<TweetCubit>(),
      child: Scaffold(
        appBar: AppBar(title: const Text('Tweets')),
        body: BlocBuilder<TweetCubit, LoadListedDataState<TweetResponseModel>>(
          builder: (context, state) {
            return state.maybeWhen(
              initial: () => const SizedBox(),
              loading: () => const Center(child: CircularProgressIndicator()),
              empty: () => const Center(child: Text('No tweets available')),
              error: (error) => Center(child: Text('Error: ${error.message}')),
              success: (tweets) => ListView.builder(
                itemCount: tweets.length,
                itemBuilder: (context, index) => ListTile(
                  title: Text(tweets[index].text),
                  subtitle: Text(tweets[index].author),
                ),
              ),
              orElse: () => const SizedBox(),
            );
          },
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () => context.read<TweetCubit>().getData(),
          child: const Icon(Icons.refresh),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Benefits of This Approach

  1. DRY (Don't Repeat Yourself): Eliminates boilerplate code across multiple features.
  2. Consistency: Ensures consistent handling of loading, success, error, and empty states.
  3. Separation of Concerns: Keeps business logic separate from UI.
  4. Testability: Makes testing easier with clearly defined states and behaviors.
  5. Scalability: New features can be added with minimal code.
  6. Maintainability: Changes to the core loading logic only need to be made in one place.

Advanced Usage

You can extend this pattern for more complex scenarios:

  1. Pagination: Add pagination methods to the base Cubit.
  2. Filtering and Sorting: Implement methods for data manipulation.
  3. Caching: Add caching mechanisms to the base implementation.

Conclusion

The abstract Cubit pattern is a powerful way to reduce boilerplate code in Flutter applications while maintaining clean architecture principles. By leveraging OOP concepts like abstraction and inheritance along with reactive and functional programming approaches, we can build more maintainable and scalable apps.

This pattern is especially useful for teams working on medium to large-scale applications where consistent state management across features is essential. It helps enforce best practices and ensures that all developers follow the same patterns.

Happy coding!


If you found this article helpful, please consider following me for more Flutter and Dart content. Share your thoughts and questions in the comments below!

Connect With Me

  • Source Code - Get the complete source code for this project
  • LinkedIn - Connect with me professionally
  • GitHub - Check out my other open source projects
  • Instagram - Follow me for behind-the-scenes and daily updates
  • Telegram - Join my channel for Flutter tips and tricks
  • Website - Visit my portfolio and blog for more tutorials

Top comments (0)