Flutter Riverpod or Bloc? 7 reasons why NOT to choose Riverpod

Flutter Riverpod or Bloc? 7 reasons why NOT to choose Riverpod

Every time we begin a new Flutter project, we face the question right away: “What state management method do I choose?” While there are a whole bunch of options out there, two prominent ones today are Bloc and Riverpod. I myself have been using Bloc since I first got into Flutter. Recently, I switched to Riverpod to test it out, and I think it’s safe to say: I’ll stick to Bloc. I’ll explain why.

1. Riverpod doesn’t go very well with Clean Architecture

For DI, I was using 2 libraries: get_it and injectable, but since Riverpod comes with pre-built solution, I had to get rid of them and switch to Riverpod’s Provider classes. This is the beginning of a disaster.

To explain why, in Clean Architecture, the Domain module does not depend on the Data module, but rather both modules depend on an abstract class, like this:

To use DI in Riverpod, for each class, we’ll have to make a Provider object. For example, the class BorrowMoneyUseCase will have borrowMoneyUseCaseProvider like this:

final borrowMoneyUseCaseProvider = Provider<BorrowMoneyUseCase>(
  (ref) => BorrowMoneyUseCase(
    ref.watch(userRepositoryProvider),
  ),
);

class BorrowMoneyUseCase {
  const BorrowMoneyUseCase(this._userRepository);

  final UserRepository _userRepository;
}

As loginUseCaseProvider depends on userRepositoryProvider so we must also declare userRepositoryProvider . But where should we declare it at? In UserRepository of the Domain module, or UserRepositoryImpl of the Data module?

Because userRepositoryProvider also depends on other providers like appApiServiceProvider ,appPreferencesProvider ,appDatabaseProvider , which belong to the Data module, it seems like userRepositoryProvider will have to be declared in the Data module in order to import and use those providers.

// module data
final userRepositoryProvider = Provider<UserRepository>(
  (ref) => RepositoryImpl(
    ref.watch(appApiServiceProvider),
    ref.watch(appPreferencesProvider),
    ref.watch(appDatabaseProvider),
  ),
);

class UserRepositoryImpl {
}

Next, we must import userRepositoryProvider (of the Data module) in borrowMoneyUseCaseProvider (of the Domain module). But, this violates the Dependency Rule of Clean Architecture: The Domain module must not depend on the Data module, in other words, we must not import any files from the Data module in the Domain module. And this is where everything falls out of place.

2. Does not natively support the Event Transformer

My boilerplate project lets you use Event Transformation like throttleTimedebounceTime, etc. without having to know RxDart. For example, to implement the live-search feature, we can use debounceTime to prevent API call spam, which we can simply do with this code:

on<KeyWordChanged>(
      _onKeyWordChanged,
      transformer: debounceTime(),
);

Or, when we want to use throttleTime for features like favoriting a post, we can simply do this:

on<FavoriteButtonPressed>(
      _onFavoriteButtonPressed,
      transformer: throttleTime(),
);

It’s hard to imagine how we can do the same with Riverpod.

3. Everything is Singleton by default

When using get_it and injectable for the DI, you can declare Bloc classes or ViewModel classes as either Factory or Singleton.

  • Most of the time, ViewModels are declared as Factory, which means multiple instances of ViewModel will exist in the app.
  • In some edge-cases, when we want to create a ViewModel with a single instance with its state shared around to all screens in the app, we declare it as a Singleton.
@Injectable() // factory
class ItemDetailViewModel {}

@LazySingleton() // singleton
class AppViewModel {}

However, with Riverpod, all providers are Singleton by default. If we want to declare ViewModel as Factory, we can use the .family modifier.

final loginViewModelProvider =
    StateNotifierProvider.family<LoginViewModel, LoginState, int>(
  (ref, uniqueId) => LoginViewModel(uniqueId),
);

class LoginViewModel {
   LoginViewModel(this.uniqueId);
   
   final int uniqueId;
}

But, since we need to pass the uniqueId , what do we pass for LoginViewModel ? Are we going to generate a random ID to use it?

4. Riverpod is too complicated

Riverpod is indeed a really complicated library with things like:

  • Provider
  • StateNotifierProvider
  • ChangeNotifierProvider
  • StateProvider
  • Notifier
  • AsyncNotifier
  • FutureProvider
  • StreamProvider
  • autoDispose
  • family
  • ref, watch, read, listen
  • ConsumerWidget
  • ConsumerStatefulWidget
  • ProviderScope
  • ProviderContainer

Meanwhile, with Bloc, we only need to know a few classes:

  • BlocBuilder
  • BlocListener
  • BlocConsumer
  • BlocSelector
  • BlocProvider
  • Bloc/Event/State

Furthermore, the Riverpod documentation is not good enough for learning, which adds to the challenge of the learning process.

5. Global scope variables can easily break your Architecture

Despite the creator saying it’s fine to declare Global Scope variables, I believe you should actually be careful with them, since their flexibility and ease of access can easily kill your architecture.

With just a single variable, Ref ref , you can retrieve any providers you want. From ViewModel, we can access the class Repository and vice versa, causing a circular dependency.final loginViewModelProvider = Provider((ref) => LoginViewModel(ref));

final loginViewModelProvider = Provider((ref) => LoginViewModel(ref));

class LoginViewModel {
  final Ref ref;

  LoginViewModel(this.ref);

  void login() {
    ref.read(repositoryProvider).login();
  }

  void updateNewState(String newState) {
    // do something
  }
}
final repositoryProvider = Provider((ref) => Repository(ref));

class Repository {
  final Ref ref;

  Repository(this.ref);

  void login() {
     ref.read(loginViewModelProvider).updateNewState('newState');
  }
}

We can also get Navigator , and show dialogs right inside ApiService .

class ApiSerivce {
  final Ref ref;
  
  ApiSerivce(this.ref);

  void request() {
    // if error
    ref.read(navigatorProvider).push(NewPage);
  }
}

6. Doesn’t support ‘mounted’

As of now, both classes ChangeNotifierProvider and StateNotifierProvider have fallen out of use. We use AsyncNotifier instead. But, one thing about AsyncNotifier is that it does not have the mounted variable to check if the Provider object has been disposed. This is important because updating new state for an already disposed provider will lead to an exception. For Bloc, you can use the isClosed variable to check.

@override
  void add(E event) {
    if (!isClosed) {
      super.add(event);
    } else {
      Log.e('Cannot add new event $event because $runtimeType was closed');
    }
  }

7. Bloc is not perfect, either

That’s not to say Bloc doesn’t have its fair share of imperfections. By far, its biggest flaw is its lengthiness. Implementing a simple screen will take way less effort with Riverpod than Bloc. But thanks to VSCode Extensions like Bloc VSCode Extension, or tools like mason_cli that help us with generating code quickly. We can also develop a tool ourselves!

Conclusion

The various issues I ran into while migrating my boilerplate project from Bloc to Riverpod are enough to convince me to stick with Bloc for now.

I’m speaking from my own experience, so there may be some inaccuracies due to lack of experience. If something doesn’t stick out right to you, feel free to leave your thoughts! I appreciate every feedback from you!

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top