Ultimate Guide to Flutter Bloc: State Management and Testing
In the Flutter ecosystem, you have several state management options to structure and scale your applications. As a Flutter newbie, you might only be familiar with setState
and provider
. Flutter Bloc stands out as a popular Flutter state management library. Learning Bloc offers many benefits.
In this comprehensive article, we'll explore techniques for implementing seamless HTTP requests and effectively handling app state with Flutter Bloc. Additionally, we'll delve into unit tests with Bloc. Here, we will ensure that the business logic in Bloc classes works as expected. Let's dive in.
Prerequisite
To get the most out of this tutorial, you should have the following:
A good grasp of Dart and Flutter.
Understanding of how state management functions.
Basic knowledge of unit testing in Flutter.
BloC Description
"BLoC" stands for "Business Logic Component." It's a state management pattern used to separate the business logic of an application from the user interface (UI) components, helping to maintain a clean and organized codebase.
The Bloc pattern typically consists of the following components:
Bloc: This is the central component. It contains the business logic and manages the state of the application.
Events: These are triggers for the Bloc to react to. Events can be user actions (e.g., button taps) or external data updates.
States: States represent the various conditions that the application can be in. The Bloc emits states in response to events. The UI responds to changes in states by updating its presentation accordingly.
UI Layer: The user interface layer (widgets) listens to the Bloc and reacts to state changes. When the Bloc emits a new state, the UI widgets update themselves to display the relevant information.
In summary, the Bloc pattern comprises of a bloc class that emits states in response to triggered events. The UI then observes the Bloc class and updates itself based on the emitted state.
To illustrate this further, let's consider a basic counter app. In this app, you can increase or decrease the counter by one. We can categorize this as follows:
Events:
The increment function
The decrement function
States:
Initial State: Counter is 0
Increment State: Counter + 1
Decrement State: Counter - 1
It's evident that when an event is triggered, the Bloc emits the corresponding state.
CRUD API implementation using Bloc for state management
We will be building a simple Flutter app, where we will implement CRUD APIs and manage the app state with Flutter Bloc and then run unit tests with Bloc test.
At the end of this tutorial, you should be able to:
Effectively handle API calls
Use Bloc for state management
Use Bloc test for testing Bloc classes
This is the end result:
This app is very similar to a project we worked on. Where we handled CRUD implementation with Dio, Clean Architecture, and Riverpod. If you’re curious, you can check it out here 👇🏽
Add Required Dependencies
Create a Flutter app, then go to your Pubspec.yaml
file and add the following dependencies for Bloc state management, testing, handling API requests and, also the equatable
dependency for our model classes. You can find these dependencies on Pub.dev
dependencies:
flutter:
sdk: flutter
dio: ^5.1.1
equatable: ^2.0.5
flutter_bloc: ^8.1.1
dev_dependencies:
bloc_test: ^9.0.0
flutter_test:
sdk: flutter
mocktail: ^1.0.0
We will use the Feature-Based folder structure for organizing the project. You can check out the repo for the complete code, to see the project structure.
Set up service class
For this tutorial, we will use the REQ|RES API. It is a hosted API we can use to simulate a real application scenario. With it, we will implement the different GET, POST, UPDATE & DELETE methods. To achieve this, we will use the Dio package to handle the API requests.
First, we will define our endpoints:
class Paths{
static String baseUrl = "https://reqres.in/api";
static String users = "/users/2";
}
Next, we will create the Dioclient
singleton class and the different methods. For brevity, we will only explore the Get method in this article, but you can check out the rest in the repo.
/// Create a singleton class to contain all Dio methods and helper functions
class DioClient {
DioClient._();
static final instance = DioClient._();
factory DioClient() {
return instance;
}
final Dio _dio = Dio(
BaseOptions(
baseUrl: Paths.baseUrl,
connectTimeout: const Duration(seconds: 60),
receiveTimeout: const Duration(seconds: 60),
responseType: ResponseType.json
),
);
///Get Method
Future<Map<String, dynamic>> get(
String path, {
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onReceiveProgress
}) async{
try{
final Response response = await _dio.get(
path,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onReceiveProgress: onReceiveProgress,
);
if(response.statusCode == 200){
return response.data;
}
throw "something went wrong";
} catch(e){
rethrow;
}
}
}
After this, we create the model class for the expected API responses. For this, we will be using equatable dependency. In Bloc, you will often deal with state changes. You will need to compare the current state with the new state to determine if there should be an update on the UI. Equatable will help make state comparisons much more efficient.
import 'package:equatable/equatable.dart';
class User extends Equatable{
final int? id;
final String? email;
final String? firstName;
final String? lastName;
final String? avatar;
const User({this.id, this.email, this.firstName, this.lastName, this.avatar});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id : json['id'],
email : json['email'],
firstName : json['first_name'],
lastName : json['last_name'],
avatar : json['avatar'],
);
}
Map<String, dynamic> toJson() {
return {
'id' : id,
'email': email,
'first_name': firstName,
'last_name': lastName,
'avatar': avatar,
};
}
@override
List<Object?> get props => [id, email, firstName, lastName, avatar];
}
Finally, we create the service class. Here, we handle the API request:
class CrudService{
Future<User> getUser() async{
try {
final response = await DioClient.instance.get(Paths.users);
final user = User.fromJson(response["data"]);
return user;
}on DioException catch(e){
var error = DioErrors(e);
throw error.errorMessage;
}
}
}
Set up the Bloc class
Now that we have set up the service class, we are already halfway done. 💪🏽 We will work on the Bloc class and connect them to the UI and service class. You can see that the Bloc will serve as the connecting link between the UI and the service, thus effectively separating the UI from the business logic.
First, we will create the Bloc class. It is important to note that the Bloc class comes with two parts:
The event class
The state class
For this GET request, this is what we want to achieve
Event: Get a single user on entry to the page
State
Loading state: when we call the API
Loaded state: when we get the user
Error state: If there is an error
Note: You can add the Bloc extension to your IDE so that you can easily create your Bloc classes with a few clicks
Now, considering what we want to achieve, we will set up the state class.
part of 'get_user_bloc.dart';
@immutable
sealed class GetUserState extends Equatable {
const GetUserState();
}
///This is the loading state to show when an event starts
class GetUserLoading extends GetUserState{
@override
List<Object?> get props => [];
}
///This is the state to be shown when user data has been gotten
class GetUserLoaded extends GetUserState{
const GetUserLoaded({this.user = const User()});
final User user;
@override
List<Object?> get props => [user];
}
///This is the Error state
class GetUserError extends GetUserState {
@override
List<Object> get props => [];
}
Considering this code snippet, here are a couple of things you should note
Here, you can notice that the
GetUserState
class extendsEquatable
to handle state comparison, just as we did for the model class.In the
GetUserLoaded
state, we pass the user object because we know that when the Bloc emits theGetUserLoaded
state, we want the user object to be returned and made accessible to the UI. Next, we will set up the Event class.
part of 'get_user_bloc.dart';
abstract class GetUserEvent extends Equatable {
const GetUserEvent();
}
class GetUser extends GetUserEvent{
@override
List<Object?> get props => [];
}
Here, we defined the event that will be triggered to call the API. Finally, we will set up the Bloc class, where the Business logic happens. The Bloc class comprises the state and event classes.
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_bloc_project/app/crud_repository/crud_repository.dart';
import 'package:equatable/equatable.dart';
part 'get_user_event.dart';
part 'get_user_state.dart';
class GetUserBloc extends Bloc<GetUserEvent, GetUserState> {
GetUserBloc({required this.crudService}) : super(GetUserLoading()) {
on<GetUser>(_onGetUser);
}
final CrudService crudService;
Future<void> _onGetUser(GetUser event, Emitter<GetUserState> emit) async {
emit(GetUserLoading());
try {
final result = await crudService.getUser();
emit(GetUserLoaded(user: result));
} catch (_) {
emit(GetUserError());
}
}
}
Let’s look into the Bloc class, note the following:
We passed in the
CrudService
to make it required whenever you call theGetUserBloc
class. Remember, theCrudService
is where we handled our API calls. We will need it in this Bloc class.We set our initial state as
GetUserLoading
. When we call the Bloc class, it emits this state first.We also wrote a function for the
GetUserEvent
namedGetUser
, so when we call the_onGetUser
, the Bloc class emits theGetUserLoading
state while thecrudService.getUser()
function that is getting a single user is running.If there is any error, the Bloc emits the
GetUserError
state.
Moving on, let’s integrate this into our UI. First of all, we have to make sure our Bloc class is accessible to the whole app.
BlocProvider(
create: (_) => GetUserBloc(crudService: crudService)..add(GetUser()),
child: const AppView()),
),
The BlocProvider
serves as a dependency injection to ensure that a single instance of Bloc is accessible to your app.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_bloc_project/crud/get_user/bloc/bloc.dart';
import 'view.dart';
class HomePage extends StatelessWidget {
const HomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
drawer: const SideDrawer(),
appBar: AppBar(
title: const Text("Get User"),
),
body: const SafeArea(child: UserProfile()),
);
}
}
class UserProfile extends StatelessWidget {
const UserProfile({
super.key,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<GetUserBloc, GetUserState>(builder: (context, state) {
switch (state) {
case GetUserLoading():
return const Center(child: CircularProgressIndicator());
case GetUserLoaded():
return Center(
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(50),
child: Image.network("${state.user.avatar}")),
Text('${state.user.firstName} ${state.user.lastName}',
style: const TextStyle(fontSize: 16, color: Colors.white),)
],
),
);
case GetUserError():
return const Text('Something went wrong!');
}
});
}
}
The code snippet above is that of our UI. Note the following:
We used a
BlocBuilder
to build the widget in response to new states.Using a switch statement, we defined the result for every case.
In the
GetUserLoaded
state, the Bloc class returns a user object. We integrated into the UI to show the items returned from the API
Voila, now you have your app state changing based on the state and what event is triggered. For the rest of the implementation of the other CRUD methods, check it out on the repo.
Unit Testing with Bloc_test
Finally, we will write unit tests for our Bloc class. It is crucial to ensure that the business logic works as expected and that the state changes correctly in response to different events or user interactions.
import 'package:flutter_bloc_project/crud/crud.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:bloc_test/bloc_test.dart';
class MockCrudService extends Mock implements CrudService{}
void main(){
group('get_user bloc', () {
late MockCrudService mockCrudService;
setUp(() {
mockCrudService = MockCrudService();
});
test('initial state is get user loading', () {
expect(GetUserBloc(crudService: mockCrudService).state,
GetUserLoading()
);
});
blocTest<GetUserBloc, GetUserState>(
'emits [ GetUserLoading,GetUserError ] when loading fails',
setUp: ()=> when(mockCrudService.getUser).thenThrow(Exception()),
build: () => GetUserBloc(crudService: mockCrudService),
act: (bloc) => bloc.add(GetUser()),
expect: () => <GetUserState>[GetUserLoading(), GetUserError()],
verify: (_)=> verify(mockCrudService.getUser).called(1),
);
blocTest<GetUserBloc, GetUserState>(
'emits [ GetUserLoading,GetUserLoaded ] when loaded successfully',
setUp: ()=> when(mockCrudService.getUser).thenAnswer((_)async => const User()),
build: () => GetUserBloc(crudService: mockCrudService),
act: (bloc) => bloc.add(GetUser()),
expect: () => <GetUserState>[GetUserLoading(), const GetUserLoaded()],
verify: (_)=> verify(mockCrudService.getUser).called(1),
);
});
}
From this code snippet, these are a few things to note
We first mocked the
CrudService
class and set it up for all the test cases on this file. It ensures that each test is run under the same conditions and does not influence subsequent tests.Then, we test to confirm that the initial state that the Bloc emits is the loading state.
We now test to ensure that when Loading fails, the states emitted are
[GetUserLoading, GetUserError ]
. To achieve this, we did the following:We set up the
mockCrudService.getUser
function with the expected response.Using the build function, we pass the
mockCrudService
in theGetUserBloc
.In the act method, we trigger the event.
We add our expectation in the expect method, which in this case is the
[GetUserLoading(),GetUserError() ]
Then, we verify if the
mockCrudService.getUser
function was called.
Using the same method, we test to ensure that when loaded successfully, the states emitted are
[GetUserLoading, GetUserloaded ]
.
With this, we have effectively covered the unit tests for the GetUserBloc
. To check out the unit and widget test for the GetUser
feature, you can check out the repo. You will also find the unit tests for Bloc that cover the other CRUD methods.
Conclusion
Finally, we've covered state management with Flutter Bloc and running unit tests for Bloc classes in Flutter. Now that you've mastered the basics, you can employ Bloc for state management in your projects, ensuring the scalability and maintainability of your code.
Bloc offers more features, including cubits, BlocListeners, MultiBlocProviders, and more. For additional information, please refer to the Bloc documentation.
If you liked this tutorial and found it helpful, drop a reaction or a comment and follow me for more related articles.
Subscribe to my newsletter
Read articles from Nikki Eke directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by