Heart for people
Mind for tech

BloC vs. Riverpod in Flutter

A Flutter developer's honest comparison

Elitsa Marinova

Engineering

Traffic lights

Written by

Elitsa Marinova
Software Developer

I started working as an intern at Baseflow on a Flutter project (coming soon to our highlight of Cases). One of the first challenging things I had to learn was state management. I was told that Baseflow has a standard sort to say: Baseflow uses BloC.

So my journey of learning BloC began. It was a new experience, to say the least, with a structure that took time getting used to. But as time passed, I learned the principles of BloC and kept using it without questioning it. I even worked on several Flutter projects, some at Baseflow, some personal, and still kept using BloC. It was clear and well-structured, and I could do anything I wanted with it. Sounds like the perfect solution, right?

But of course, at Baseflow, we strive to give freedom in many aspects, including teams choosing their tech stack as much as possible. And when I was tasked with deepening my Flutter knowledge, with a lot of freedom in terms of what I wanted to learn, how to learn it, etc., I started researching interesting topics. At some point, I came across the concept of state management again and remembered that BloC is by far not the only solution out there - others are quite popular, such as Riverpod.

I took the liberty of exploring more. After all, I believe that most of my colleagues will agree here - why work with only one solution without knowing what the rest of them can offer?

I started reading about Riverpod. It was rather interesting; it dealt with problems in a different manner than BloC. That’s when I decided to dig deeper and learn, at least somewhat, about Riverpod. There are two possible outcomes here - I fall in love with it and start convincing my colleagues to switch from BloC to Riverpod, or I don’t like it and continue my Flutter career more than confident in choosing BloC to work with.

Initial Setup

First, I had to decide how to even compare the two. I chose to go with side-by-side code comparison. I started off by creating a Flutter project. I chose to follow Clean Architecture. I created shared data and core layers. This would also ensure that I will always get the same data; the only thing that will be changing would be the business logic. Full freedom for implementing Riverpod and BloC, abstracted from anything else.

The project I chose was a Trello-type board. You have some columns with tasks inside them, and these columns are part of a board. A reloading button, two separate loading views, one with and one without data, an add column button, etc. All of this just so I can explore states deeper. One of the main questions I had was - if I remove a column, how will I update the board to display that loading process in BloC vs. Riverpod? How can I display loading views with and without data? How is that handled by BloC vs. Riverpod? Empty or error views?

The Journey

I began by creating the UI, very straightforward, simple, and ready to hook up to any state management. Then created the core and data layers. Ready to be used by any state management.

BloC

I continued by implementing the BloC part first, since I am familiar with it and I can tackle it quickly. The plan was to come up beforehand with all the states, write a bunch of boilerplate code like most of the cubits and states and use them in the UI, and use the data layer that was already set up.

Models

My structure was simple; here is an example of my board entity. I gave it an id and a list of columns. I even extended the class with Equatable to make sure my state can track any changes.

@immutable
class BoardEntity extends Equatable {
  BoardEntity({required this.id, required List<ColumnEntity> columnsList})
    : columnsList = List<ColumnEntity>.unmodifiable(columnsList);

  final int id;
  final List<ColumnEntity> columnsList;
  
  // ...
}

My states and the UI

When it comes to showing different states to the UI, BloC works as follows - you need a BlocProvider to make sure the cubit is accessible from that point in the widget tree down, and you need a BloC Widget. This usually is one of four: BlocBuilder, BlocSelector, BlocListener or BlocConsumer. For the BoardPage, I went with a BlocBuilder:

body: BlocBuilder<BoardPageCubit, BoardPageState>(
  builder: (context, state) {
    if (state is BoardPageLoading) {
      return const Center(child: CircularProgressIndicator());
    }
    if (state is BoardPageEmpty) {
      return Padding(padding: .all(16.0), child: BoardColumn.empty());
    }
    if (state is BoardPageError) {
      return BoardViewError();
    }
    if (state is HasDataState) {
      return BoardView(board: state.boardEntity, isOverlayLoading: state.isLoading);
    } else {
      return const SizedBox.shrink();
    }
  },
),

An example of a cubit method, together with a state class:

Future<void> addCard() async {
  emit(state.copyWith(loading: true));
  final updatedBoardColumn = await _service.addCardToColumn(
    columnId: state.boardColumnEntity.id,
  );
  emit(
    state.copyWith(boardColumnEntity: updatedBoardColumn, loading: false),
  );
}
class BoardColumnState extends Equatable {
  const BoardColumnState({
    required this.columnEntity,
    this.loading = false,
    this.errorMessage,
  });

  final ColumnEntity columnEntity;
  final bool? loading; 
  final String? errorMessage;

  @override
  List<Object?> get props => [columnEntity, loading, errorMessage];

  BoardColumnState copyWith({
    ColumnEntity? columnEntity,
    bool? loading,
    String? errorMessage,
  }) {
    return BoardColumnState(
      columnEntity: columnEntity ?? this.columnEntity,
      loading: loading ?? this.loading,
      errorMessage: errorMessage ?? this.errorMessage,
    );
  }
}

Riverpod

After having the basic functionality with BloC, it was time to write the Riverpod part. The journey was full of ups and downs. First, I started with a deep dive into the documentation and tried to grasp how to even begin. At this point, it's important to mention the difference between BloC and Rivepod. BloC is a state management while Riverpod is a data management solution that also gives the opportunity to deal with your UI states pretty well. But what does this mean? Let's dive in.

Providers

I started by creating a boardProvider.

final boardProvider =
    AsyncNotifierProvider.autoDispose<BoardNotifier, BoardEntity>(
      BoardNotifier.new,
    );

And quickly learned that I need a BoardNotifier as well, something that looks like this:

class BoardNotifier extends AsyncNotifier<BoardEntity> {
  @override
  Future<BoardEntity> build() async {
    final service = ref.watch(boardServiceProvider);
    return service.fetchBoard();
  }

  Future<void> addColumn() async {
    // ...

UI

And then I went my jolly way to do the states in the UI. Nothing much is needed there; basically, I extended my widget class with a ConsumerWidget to be able to access the WidgetRef. If you are unfamiliar with Riverpod terms, click here. And then it felt like the first sunshine ray after the long Dutch winter hit my face.

body: boardRef.when(
  data: (value) => BoardView(board: value),
  loading: () => const Center(child: CircularProgressIndicator()),
  error: (e, _) => const Text("Error"),
),

You want to tell me that I do not need to write code to have different states in my UI? That Riverpod just takes care of that for you? What more could a person need? And then my journey of telling my colleagues that Riverpod is pretty cool began. I mean, this is five lines of code in comparison to the 10+ lines for BloC in the UI only, not to mention having to write state and cubit classes. Riverpod got some major plus points here.

That happiness was short-lived.

The struggle

As I mentioned already, I set up the project in such a way that I made sure there would be some challenges, and the first one presented itself pretty soon. Question for you, how would you add a loading state with data?

In BloC, we simply did:

if (state is HasDataState) {
  return BoardView(
    board: state.boardEntity,
    isOverlayLoading: state.isLoading,
  );
} 

The state is designed to actually have that information. But in Riverpod, if you are receiving data, you are not refreshing, and if you are refreshing/reloading, you don't have any data, see code above. I went into a deep dive into how Riverpod worked, and then I figured it out - we need another provider. It will simply be a provider that returns a boolean that tracks the "loading with data" state.

At some point, it was time to implement some functionalities on a specific column, and that's when I learned that I have two options. Option one is to keep using the board provider. You have access to most of your data, but you still have to pass your columnId if you want to delete the column.

Future<void> deleteColumn(String columnId) async {
  ref.read(isOverlayLoadingProvider.notifier).state = true;
  final service = ref.read(boardServiceProvider);
  final result = await service.deleteColumn(columnId: columnId);
  state = AsyncData(result);
  ref.read(isOverlayLoadingProvider.notifier).state = false;
}

So basically, you cannot really utilise the state to access the ID, as you might be able to do that in BloC. In BloC, I had a ColumnState, and each state, of course, had an ID. I expected that perhaps Riverpod would provide us a way to do it similarly. But it turns out that's not a common practice.

Nevertheless, I took the challenge to create a columnProvider, and it worked to some extent:

final columnProvider = AsyncNotifierProvider.autoDispose
    .family<ColumnNotifier, ColumnEntity, String>(
      (String id) => ColumnNotifier(id),
    );

With this column provider, I can work with the columns and their respective states. Though this solution seems like the correct one, I believe there isn't only one correct way of doing things when it comes to Riverpod. Both ways of solving the problem are different in their nature, and which one you choose will depend entirely on the data that you are working with.

Let's say you are fetching a board that contains a list of column ids only, no column data, and you have a method that fetches a column for you. I would certainly opt for a columnProvider. Since Riverpod is a data-binding solution, it would seem this is its intended use case.

The other option for this scenario is to have a boardProvider holding all your data that is also responsible for it. Rather a cumbersome experience, but it works just fine.

Final thoughts

When it comes to Riverpod, you can do a lot. It's versatile, and the learning curve is not that steep. If you have a project that you want to get up and running quickly, it's a great option. What I think it lacked was that it was very opinionated, but the documentation didn't really provide real-life scenarios on how to deal with more complex situations. And since it is very much dependent on the data, I thought it was difficult to manipulate it for your own functionalities.

For now, I will be sticking with BloC (we also teach BLoC in our Flutter training by the way). I am not saying that this is the perfect, one-fits-all solution. BloC has its disadvantages as well. There is a fair amount of boilerplate code, and the learning curve is steep. Nevertheless, where BloC wins for me is how versatile it is. You can manipulate the way you are working with your data in any way you need, and that also rather easily. It's suitable for complex projects, and it's very scalable.

What you should choose for your own Flutter app depends on the complexity, scalability needs, and deadlines of your project, and also on what you are most confident with.