Home
/
Blog
/
Why we decided to switch from Bloc to Riverpod

Why we decided to switch from Bloc to Riverpod

Denis Groshev
Denis Groshev
Flutter Developer
0 12 min
Why we decided to switch from Bloc to Riverpod

Our mobile development team believed in Flutter back in 2017. For many years, we have remained evangelists of the technology and know the so-called pros and cons.

In this article, we'll talk about our experience using Bloc, and why we decided to switch to Riverpod.

Where did the idea come from?

It's no secret that choosing the right state manager, architecture, and structure plays a significant role in the development of a project. In this article, we'll focus on the first point — the state manager.


We used Bloc for a long time on all our projects. It's a great solution for state management, but it has a number of drawbacks that we regularly encountered during work. Now, let's talk about each of these in more detail.

  • Variability of approaches to logic implementation. Bloc includes several components that lean towards different architectural approaches — the lightweight Cubit and the full-fledged Bloc. This variability can be both a plus and a minus, as it can lead to developer disunity and mix approaches during development.

  • Boilerplate. When using a full-fledged Bloc, you have to write a lot of repetitive boilerplate — describing events for a page and registering the necessary handlers in the Bloc itself. While this feature may not be as noticeable in small applications, it becomes a real torture in large projects.

  • Imposing libraries for use. This point is directly related to the Bloc developer. To test the logic, bloc_test, which includes mocktail, is used. Bloc_test itself is a great tool that allows you to thoroughly test behavior, but by adding this dependency to pubspec, we automatically pull in mocktail. Thus, the library developer does not provide alternatives and imposes their libraries, which we would prefer not to use.

  • Mocktail. It forces us to manually create mocks, while our team is used to automating such processes. Therefore, here we prefer Mockito.

These combined factors led us to consider alternative options for state management. We needed a state manager that would solve all these problems but still preserve ease of use and sufficient code readability. Currently, there are a lot of solutions with their own pros and cons. We researched the most popular ones and decided to settle on one — Riverpod.
 

State Manager Options

When exploring the existing libraries, we evaluated them based on important criteria for us:
  • Documentation

  • Amount of boilerplate

  • Testability

  • Community size and possible risks

We considered: Bloc, Riverpod, Triple, GetX, Provider, Redux, and several of its wrappers — Fish Redux and Async Redux. We assessed the pros and cons of each option, and some libraries were immediately ruled out due to significant drawbacks and not meeting our requirements. As a result of the comparative analysis, only two new libraries remained for us — Riverpod and Triple.


We wanted to get to know these libraries better, so we implemented a small test project with each of the managers, which helped us choose a favorite. Riverpod turned out to be more convenient than Triple, more documented, and had greater community support. Thus, we decided to use the more popular solution to minimize risks.
If choosing a specific technology is relevant for you, we recommend applying each of the considered libraries to a small test project to get an idea of their use and decide what suits you best.


We also compared code in Riverpod against code in Bloc. And this comparison exceeded all expectations. It turned out that everything with Riverpod looks, writes, and reads much simpler than the same thing implemented in Bloc. This assured us that we could achieve the desired result by applying the new manager. Now we propose to focus on the main differences between these two libraries. 

Key Differences Between Riverpod and Bloc

Let's take a look at the code for working with a todo-list using Riverpod. 

class TodoProvider extends StateNotifier<TodoState> {
  TodoProvider(
    this._todoRepository, {
    @visibleForTesting TodoState? initialState,
  }) : super(initialState ?? const TodoState());

  final TodoRepository _todoRepository;

  Future<void> initializeTodos() async {
    final savedTodos = await _todoRepository.fetchAll();
    
    // To-do list fetches from Hive, so it doesn't take much time.
    // In this case we use one second delay to avoid blink of progress bar
    await Future.delayed(const Duration(seconds: 1), () {
      state = state.copyWith(
        isLoading: false,
        todos: savedTodos.entries.map(TodoUi.fromMapEntry).toList(),
      );
    });
  }
Future<void> createTodo(String todo) async {
    await _todoRepository.save(TodoHive(todo));
    final savedTodos = await _todoRepository.fetchAll();
    state = state.copyWith(
      todos: savedTodos.entries.map(TodoUi.fromMapEntry).toList(),
    );
  }

  Future<void> removeTodo(int key) async {
    await _todoRepository.removeByKey(key);
    state = state.copyWith(
      todos: List.of(state.todos)..removeWhere((element) => element.key == key),
    );
  }
}
Now, let's look at the same logic implemented with Bloc. 
 
abstract class TodoEvent extends Equatable {
  const TodoEvent();

  @override
  List<Object?> get props => [];
}

class TodoInitialed extends TodoEvent {
  const TodoInitialed();
}

class TodoCreated extends TodoEvent {
  const TodoCreated(this.todo);

  final String todo;

  @override
  List<Object?> get props => [todo];
}

class TodoRemoved extends TodoEvent {
  const TodoRemoved(this.key);

  final int key;

  @override
  List<Object?> get props => [key];
}

class TodoBloc extends Bloc<TodoEvent, TodoState> {
  TodoBloc(TodoRepository todoRepository)
      : _todoRepository = todoRepository,
        super(const TodoState.initial()) {
    on<TodoInitialed>(_onTodoInitialed);
    on<TodoCreated>(_onTodoCreated);
    on<TodoRemoved>(_onTodoRemoved);
  }
final TodoRepository _todoRepository;

  Future<void> _onTodoInitialed(TodoInitialed event, Emitter emit) async {
    final savedTodos = await _todoRepository.fetchAll();
    await Future.delayed(const Duration(seconds: 1), () {
      emit(TodoState.update(
        savedTodos.entries
            .map<TodoUi>((e) => TodoUi.fromHiveModel(e.key as int, e.value))
            .toList(),
      ));
    });
  }

  Future<void> _onTodoCreated(TodoCreated event, Emitter emit) async {
    await _todoRepository.save(TodoHive(event.todo));
    final savedTodos = await _todoRepository.fetchAll();
    emit(TodoState.update(
      savedTodos.entries
          .map<TodoUi>((e) => TodoUi.fromHiveModel(e.key as int, e.value))
          .toList(),
    ));
  }

  Future<void> _onTodoRemoved(TodoRemoved event, Emitter emit) async {
    await _todoRepository.removeByKey(event.key);
    emit(TodoState.update(List.of(state.todos)
      ..removeWhere((element) => element.key == event.key)));
  }
}

As can be seen from the example, implementing the same logic requires more lines of code when using Bloc. Let’s elaborate on how this works.

Bloc matches specific user events with the necessary handlers. This method of description is more abstract, which is undoubtedly a plus, but the amount of code that needs to be described each time negates this advantage.

There is also an option to use Cubit, a simplified version of Bloc. This can visually make the code look almost the same as with Riverpod, but there is a difference when pushing a new state to the UI.

When using Cubit to process large amounts of data, there's a possibility that the user may leave the screen before the data is fetched. In this case, you must add checks to the logic to ensure the Cubit is not closed, avoiding unnecessary errors. Such a problem has never been noticed with Riverpod; hence, the logic is not burdened with additional specific checks.

We are more accustomed to working with a full-fledged Bloc; thus, compared to Bloc, Riverpod does not have events per se. Handlers are addressed directly through the provider in which they are implemented. That is, a specific button is responsible for calling a particular method in the handler, which changes the UI state after performing necessary business operations. Let’s dive deeper to find more differences.

In-depth Analysis

It’s one thing to apply a library, and another to look at its internal structure to understand the code quality and internal principles.

The most frequently used object in a project is StateNotifier, which allows you to operate with more complex entities consisting of the required set of primitives—classes. Essentially, it works just like Bloc, but the handler is called directly.

In the case of Bloc, when an event comes in, it is matched with a registered handler for that event type. After executing the necessary actions, the handler places a new state in the StreamController. This is the state we listen to in the UI. Using Stream guarantees regular state updates when new events arrive.

Finding differences in the logic of Riverpod’s operation, in this sense, is not particularly appropriate because the principle of operation is almost the same. However, there is a difference in another aspect, which we will discuss now.

Bloc uses its own Provider extension, which allows screen rebuilding when the state changes. It is based on working with the application context, so we need to wrap the necessary widgets in BlocProvider to give access to the Bloc to the underlying subtree. To work with the state itself, a BlocBuilder or its advanced analog, BlocConsumer, is required.

Riverpod differs in that it does not rely on context. It creates its own container for providers, eliminating the need for additional wrappers to get our state. This reduces unnecessary nesting in the tree. Moreover, Riverpod can be adapted as a Dependency Injection to use objects in any layer of the application. Just have a ref-reference to get the necessary infrastructure object, data, or state. This adds more flexibility to usage, though it places more responsibility on the developer.

Both libraries provide identical tools for working in the UI layer. There is the ability to subscribe to multiple providers, rebuild the screen depending on specific parameters, use convenient extensions, etc.

The difference lies only in the syntax, which in any case is an individual point in any applicable solution.

First Experience Using Riverpod

In new projects, we decided to use the chosen technology, but before that, it would be good to test it out. Management came up with the idea to ease internal company management processes by implementing our own application. We decided to get better acquainted with the new technology on it.

Riverpod suited our project structure quite well, so the transition to it was easy. We got a better understanding of state management methods and registering the dependencies we needed, and the development went much faster and became more interesting. The boilerplate code significantly reduced, which was literally a breath of fresh air for us.

Soon, a new commercial project aimed at the agricultural sector of some African countries began. The project is quite large, involving several user roles, and describing all the events for each screen with Bloc would have been torturous. Development with Riverpod went quite smoothly. Overall, the code turned out to be quite beautiful and concise. The project is already fully implemented and launched into production. Therefore, based on this practical experience, we can highlight both positive and negative aspects.

AfriCash
AfriCash
mobile

Marketplace for trading agricultural products

Testing

Separately, I would like to touch on the topic of code testing.


We pay a lot of attention not only to writing the code itself but also to testing the final logic. Therefore, writing tests should not create significant inconveniences; on the contrary, it should be as simplified and understandable as possible.


If you look at the code structure necessary for testing a Bloc, you will see that it requires constantly describing the same cumbersome construction. Hence, testing a Bloc also becomes burdensome by creating yet another boilerplate.


On the other hand, testing Riverpod is much simpler and looks much more elegant. Everything needed to create a test fits in a few lines, making testing the logic implemented with Riverpod a real pleasure. Let's look at a specific example.

blocTest<TodoBloc, TodoState>(
  'Check getting initial todos',
  build: () {
    when(todoRepository.fetchAll()).thenAnswer((_) async => savedTodos);
    return bloc;
  },
  act: (bloc) => bloc.add(const TodoInitialed()),
  wait: const Duration(seconds: 1),
  expect: () => [
    const TodoState.update(savedTodosUi),
  ],
);
test('Check get all todos', () async {
  when(todoRepository.fetchAll()).thenAnswer((_) async => savedTodos);
  
  await container.read(provider.notifier).initializeTodos();
  await Future<void>.delayed(const Duration(seconds: 1));
  expect(container.read(provider).todos, savedTodosUi);
});

Indeed, the code looks shorter, but it is worth noting a significant drawback of testing logic with Riverpod that we have to put up with. Bloc has an expect parameter that expects a list of all states that occur while processing a dispatched event.


For example, when a button is pressed that makes a request to the server, we can expect two states: the first will place a loader on the button, showing the start of a long operation, and the second will return the necessary data and reflect it on the screen.


Bloc allows specifying all expected states in a list and ensuring they come in precisely that order.


In the case of Riverpod, we expect our method to be executed and only check the final state. However, succinctly verifying the states that occur during execution is not possible. For this, we have to use ref.listen to subscribe to all changes, where we can check all intermediate states.


This is not very convenient, and as the number of checks increases, the test becomes less and less attractive. However, we are still searching for a more elegant solution for testing such cases.


We also want to share another solution we found for convenient testing with Riverpod. When there is a need to test a button press that appears only after the necessary data is received, we do not need to describe a large test for the entire chain. We can describe a small test covering the necessary piece of logic and then create a provider in another test, specifying the required initial state.
 

TodoProvider(
  this._todoRepository, {
  @visibleForTesting TodoState? initialState,
}) : super(initialState ?? const TodoState());
This can be done using an optional parameter available only during logic testing. With this approach, we break down large tests into smaller pieces, which are much easier to work with.

Conclusion

At the moment, we are completely satisfied with using Riverpod. It is convenient and provides us, as developers, with everything necessary for the quick and comfortable development of applications. It fully meets all our requirements for a state manager.


Thanks to this change, we were able to shift our focus to other aspects of our code. We redesigned the error-handling system, paid more attention to Dependency Injection, set new goals in testing, and revised some architectural approaches. Thus, the change of state manager became not just a pleasant improvement but a new driving force in the development of our codebase, which also reflects in the overall development of our team.


Let's summarize the points we managed to address by applying the new state manager:

  • Unlike Bloc, Riverpod does not contain additional tools for implementing logic, such as Cubit. We only have a set of providers that work with states or needed objects. In other words, we eliminated unnecessary variability and can be sure that all code will be written in the same architectural approach.

  • Minimized boilerplate. We no longer need to generate essentially identical code describing all methods of user interaction with the screen. We no longer need event classes and all additional wrappers like BlocProvider and BlocConsumer.

  • Tests have become simpler. Previously, Bloc was used with bloc_test, which were quite cumbersome. Now, within simple tests, providers can be tested for all necessary cases.

  • We are no longer under the influence of the Bloc developer and can independently choose the technology stack that suits us best.

  • Working with state in the UI has become more convenient. A specific widget can be easily subscribed to updates through ref.watch.

  • We also gained new experiences and insights from working with Riverpod, further broadening our expertise and perspective. 

What is Bloc?
What is Riverpod?
What is a state manager?

Share

Feel free to contact us

Book an appointment

Tell us about your project
Name
Contact
Message
Attach file +
Request to get files
Name
Send files
Message
Thanks!
Your request has been sent
After processing, our manager will contact you