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.
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.
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.
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),
);
}
}
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.
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.
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.
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());
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:
BLoC (Business Logic Component) is an architectural pattern primarily used for developing Flutter applications. BLoC helps separate business logic from the user interface, making the application more manageable, testable, and scalable.
Key principles of BLoC:
Riverpod is a state management library for Flutter that offers developers enhanced features, such as:
Educe KPI and enhance your company’s competitiveness based on analyzing business operational data from 1000+ companies
We ensured revenue growth for Tiffin Loop and contributed to environmental protection!
Multi-functional platform and mobile application for automating the educational process