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:
- Object-Oriented Programming (OOP): We use abstract classes and inheritance to create reusable components.
- Reactive Programming: The Cubit pattern follows reactive principles, emitting states that UI components can react to.
- Functional Programming: We leverage functional concepts like immutable states and the Either type for error handling.
-
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
Run the following command to install these dependencies:
flutter pub get
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();
}
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;
}
After defining these files, run code generation:
flutter pub run build_runner build --delete-conflicting-outputs
Understanding the Abstraction
Let's break down what's happening in our LoadListedDataCubit
:
-
Generic Type:
<T>
allows us to reuse this Cubit for any data type. - Initial State: The constructor sets up an initial state and triggers data loading.
- State Management: The Cubit handles five states (initial, loading, success, error, and empty).
-
Template Method Pattern: The
fetchData()
method is abstract, whilegetData()
provides the common implementation. -
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();
}
}
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),
),
),
);
}
}
Benefits of This Approach
- DRY (Don't Repeat Yourself): Eliminates boilerplate code across multiple features.
- Consistency: Ensures consistent handling of loading, success, error, and empty states.
- Separation of Concerns: Keeps business logic separate from UI.
- Testability: Makes testing easier with clearly defined states and behaviors.
- Scalability: New features can be added with minimal code.
- 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:
- Pagination: Add pagination methods to the base Cubit.
- Filtering and Sorting: Implement methods for data manipulation.
- 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)