Mastering the BLoC Pattern in flutter for large scale apps
The BLoC pattern, short for Business Logic Component, has gained popularity in the Flutter community as a state management solution. It provides a clear separation of concerns, making it suitable for large-scale projects with complex business logic. In this article, we will explore how to efficiently use the BLoC pattern in large-scale projects, along with practical code examples.
What is the BLoC Pattern?
The BLoC pattern is a design pattern that separates the business logic of an application from the UI. It consists of three main components:
Business Logic Component (BLoC):
It represents the business logic of the application, such as data processing, state management, and communication with external services. It takes input from the UI and emits output that the UI can consume.
Streams:
Streams are used to handle data flow between the BLoC and the UI. They allow for asynchronous handling of data, making it easy to handle data updates in real-time.
UI Layer:
The UI layer is responsible for displaying the data and handling user interactions. It communicates with the BLoC to fetch data and trigger actions.
Efficiently Using BLoC in Large-Scale Projects
When working with large-scale projects, it’s important to follow best practices to ensure the BLoC pattern is used efficiently. Here are some tips:
Separation of Concerns:
One of the main advantages of the BLoC pattern is its clear separation of concerns. It’s important to keep the business logic in the BLoC and the UI-related code in the UI layer. Avoid mixing business logic with UI code to keep the codebase maintainable and scalable.
Single Responsibility Principle (SRP):
Follow the SRP, which states that a class should have only one reason to change. Keep the BLoC focused on a specific feature or functionality, and avoid adding unrelated logic. This helps in easier maintenance and testing.
Reusability:
Make your BLoCs reusable by designing them to be independent of the UI layer. This allows you to easily plug them into different parts of your application and encourages code reusability.
State Management:
Choose an appropriate state management solution for your BLoCs. BLoC pattern works well with state management libraries like RxDart or Provider. Use streams to handle data updates and listen to them in the UI layer to efficiently manage the state of your application.
Testing:
Write unit tests for your BLoCs to ensure they are working as expected. Mock the dependencies and use tools like Mockito to isolate the BLoC and test its functionality independently.
Practical Examples (Basic)
Let’s take a look at some practical code examples to illustrate the efficient use of the BLoC pattern in large-scale projects.
Authentication BLoC
class AuthenticationBloc {
final _isAuthenticatedController = StreamController<bool>.broadcast();
Stream<bool> get isAuthenticatedStream => _isAuthenticatedController.stream;
void authenticateUser() {
// Perform authentication logic
_isAuthenticatedController.sink.add(true); // Emit the result to the UI
}
void dispose() {
_isAuthenticatedController.close();
}
}
In this example, we have an AuthenticationBloc that handles user authentication. It has a stream that emits a boolean value indicating whether the user is authenticated or not. The UI layer can listen to this stream and update the UI accordingly. The business logic of authentication is separated from the UI, making it easier to test and maintain.
ProductList BLoC
class ProductListBloc {
final _productsController = StreamController<List<Product>>.broadcast();
Stream<List<Product>> get productsStream => _productsController.stream;
void fetchProducts() async {
try {
List<Product> products = await ProductService.fetchProducts();
_productsController.sink.add(products); // Emit the fetched products to the UI
} catch (e) {
// Handle error
}
}
void dispose() {
_productsController.close();
}
}
In this example, we have a ProductListBloc that handles fetching of products from an external service. It has a stream that emits a list of products. The UI layer can listen to this stream and display the products to the user. The business logic of fetching products is isolated in the BLoC, making it reusable and easy to test.
Cart BLoC
class CartBloc {
final _cartItemsController = BehaviorSubject<List<CartItem>>.seeded([]);
Stream<List<CartItem>> get cartItemsStream => _cartItemsController.stream;
void addItemToCart(Product product) {
// Perform logic to add item to cart
List<CartItem> currentCartItems = _cartItemsController.value;
currentCartItems.add(CartItem(product));
_cartItemsController.sink.add(currentCartItems); // Emit updated cart items to the UI
}
void removeItemFromCart(CartItem cartItem) {
// Perform logic to remove item from cart
List<CartItem> currentCartItems = _cartItemsController.value;
currentCartItems.remove(cartItem);
_cartItemsController.sink.add(currentCartItems); // Emit updated cart items to the UI
}
void dispose() {
_cartItemsController.close();
}
}
In a real-world large-scale project, you may want to consider more advanced techniques for initializing and managing BLoCs efficiently.
Here are some suggestions:
Use Dependency Injection (DI):
Instead of directly creating and providing BLoCs in the MultiBlocProvider at the root of the app, you can use a DI library like get_it, provider, or kiwi to manage the instantiation and dependency injection of BLoCs. This allows for better separation of concerns and makes it easier to swap out implementations of BLoCs if needed.
// Example using the `get_it` package for DI
// Define the main app widget
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My App',
home: MultiBlocProvider(
providers: [
BlocProvider<UserBloc>(
create: (context) => getIt<UserBloc>(), // Use DI to get the UserBloc instance
),
BlocProvider<ProductListBloc>(
create: (context) => getIt<ProductListBloc>(), // Use DI to get the ProductListBloc instance
),
BlocProvider<CartBloc>(
create: (context) => getIt<CartBloc>(), // Use DI to get the CartBloc instance
),
],
child: HomeScreen(),
),
);
}
}
Use BlocProvider.value for performance optimization:
If you have a performance-sensitive UI, you can use BlocProvider.value instead of BlocProvider.create to provide the BLoCs. This prevents unnecessary re-creation of BLoCs when the widget tree rebuilds, which can improve performance.
// Example using BlocProvider.value for performance optimization
// Define the main app widget
class MyApp extends StatelessWidget {
final UserBloc userBloc = UserBloc(); // Create BLoCs outside of build method
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My App',
home: MultiBlocProvider(
providers: [
BlocProvider.value(
value: userBloc, // Use BlocProvider.value to provide the pre-created UserBloc instance
),
// ...
],
child: HomeScreen(),
),
);
}
@override
void dispose() {
userBloc.close(); // Close the BLoCs when the app is disposed
super.dispose();
}
}
Use BLoCProvider to manage BLoCs lifecycle:
The bloc library provides a BLoCProvider that can manage the lifecycle of BLoCs for you. This can be useful in a large-scale project where you want to automatically dispose of BLoCs when they are no longer needed, and recreate them when needed again.
// Example using BLoCProvider to manage BLoCs lifecycle
// Define the main app widget
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My App',
home: BlocProviderTree(
blocProviders: [
BlocProvider<UserBloc>(
creator: (context, _) => UserBloc(), // Use creator to create the UserBloc instance
),
// ...
],
child: HomeScreen(),
),
);
}
}
These are just a few examples of how you can efficiently initialize and manage BLoCs in a large-scale Flutter project. The specific approach may vary depending on your project’s architecture and requirements, but
the key is to carefully consider the best practices for your project and implement them accordingly.
In addition to the techniques mentioned above, here are some other tips for efficiently using the BLoC pattern in large-scale Flutter projects:
Use BLoC composition:
Instead of having a single monolithic BLoC for the entire app, break down the functionality into smaller, focused BLoCs that can be composed together. This promotes reusability and maintainability.
// Example of BLoC composition
class UserBloc extends Bloc<UserEvent, UserState> {
final UserRepository userRepository;
UserBloc(this.userRepository) : super(UserInitialState());
@override
Stream<UserState> mapEventToState(UserEvent event) async* {
if (event is FetchUserEvent) {
yield UserLoadingState();
try {
final user = await userRepository.fetchUser();
yield UserLoadedState(user);
} catch (error) {
yield UserErrorState(error);
}
}
// ... other event handling logic
}
}
class ProductListBloc extends Bloc<ProductListEvent, ProductListState> {
final ProductRepository productRepository;
ProductListBloc(this.productRepository) : super(ProductListInitialState());
@override
Stream<ProductListState> mapEventToState(ProductListEvent event) async* {
if (event is FetchProductListEvent) {
yield ProductListLoadingState();
try {
final productList = await productRepository.fetchProductList();
yield ProductListLoadedState(productList);
} catch (error) {
yield ProductListErrorState(error);
}
}
// ... other event handling logic
}
}
// Usage in the app
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<UserBloc, UserState>(
builder: (context, userState) {
return BlocBuilder<ProductListBloc, ProductListState>(
builder: (context, productListState) {
// Use the states from UserBloc and ProductListBloc
// to build the UI
},
);
},
);
}
}
Optimize state management:
BLoC pattern allows for fine-grained control over state management. You can optimize state management by using state-equatable objects, using distinct or distinctUntilChanged operators in your BLoC, and avoiding unnecessary state updates.
// Example of optimizing state management
class UserState extends Equatable {
final String name;
final int age;
UserState(this.name, this.age);
@override
List<Object?> get props => [name, age];
}
class UserBloc extends Bloc<UserEvent, UserState> {
// ...
@override
Stream<UserState> mapEventToState(UserEvent event) async* {
// Use distinct to prevent unnecessary state updates
yield* event.map(
fetchUser: (event) async* {
final user = await userRepository.fetchUser();
if (user != state.user) {
// Only yield a new state if the user object has changed
yield UserLoadedState(user);
}
},
// ... other event handling logic
);
}
}
Use BLoCListener sparingly:
BlocListener is a useful widget for reacting to state changes in a BLoC, but it should be used judiciously in a large-scale project as it can easily lead to redundant rebuilds. Instead, prefer using BlocBuilder which allows you to selectively rebuild parts of the UI based on the state changes.
// Example of using BlocBuilder instead of BlocListener
// Not optimal in a large-scale project
BlocListener<UserBloc, UserState>(
listener: (context, state) {
// Handle state changes
},
child: // UI widgets
)
Optimal approach using BlocBuilder:
// Optimal approach using BlocBuilder
BlocBuilder<UserBloc, UserState>(
builder: (context, state) {
// Build UI based on the state
// and react to state changes
if (state is UserLoadedState) {
// Render UI for loaded user state
} else if (state is UserErrorState) {
// Render UI for error state
} else {
// Render UI for other states
}
},
)
Use dependency injection:
When working with BLoC pattern, it’s important to decouple your BLoCs from concrete implementations of dependencies, such as repositories or APIs. Use a dependency injection framework, such as get_it or provider, to manage your dependencies and allow for easy testing and swapping of implementations.
// Example of using dependency injection with get_it
// Register the UserRepository implementation in your DI container
getIt.registerLazySingleton<UserRepository>(() => UserRepositoryImpl());
// Use the registered UserRepository in your BLoC
class UserBloc extends Bloc<UserEvent, UserState> {
final UserRepository userRepository;
UserBloc(this.userRepository) : super(UserInitialState());
// ... rest of the BLoC implementation
}
By following these best practices and optimizing your use of the BLoC pattern, you can achieve efficient and scalable state management in your large-scale Flutter projects.
Conclusion
The BLoC pattern is a powerful state management solution for large-scale Flutter projects. By following best practices such as separation of concerns, SRP, reusability, and efficient state management, you can effectively use the BLoC pattern to manage complex business logic and ensure a scalable and maintainable codebase. With practical code examples, you can implement the BLoC pattern in your Flutter projects and leverage its benefits for efficient development.