Ultimate Guide to Flutter Bloc: State Management and Testing

Ultimate Guide to Flutter Bloc: State Management and Testing

·

11 min read

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 extends Equatable 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 the GetUserLoaded 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 the GetUserBloc class. Remember, the CrudService 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 named GetUser, so when we call the _onGetUser, the Bloc class emits the GetUserLoading state while the crudService.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 the GetUserBloc.

    • 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.