Choosing the Right Solution: BLoC vs Riverpod in Practice
Choosing the Right Solution: BLoC vs Riverpod in Practice
After learning BLoC, Cubit, and Riverpod in isolation, the most pressing real-world question is: which one should you actually use? There is no universal answer, but there is a structured decision framework. This lesson compares both solutions across five critical dimensions, then walks through a concrete refactor that migrates a Cubit-based feature to a Riverpod Notifier.
Dimension 1 — Boilerplate and Verbosity
BLoC imposes the most structure. Even a simple authenticated-user feature requires an event class hierarchy, a state class hierarchy, and the Bloc class itself. Cubit removes the event layer but still needs a dedicated state class. Riverpod's Notifier collapses everything into one class:
Same Feature — Cubit vs Riverpod Notifier
// --- CUBIT APPROACH (3 files minimum) ---
// auth_state.dart
abstract class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final User user;
AuthAuthenticated(this.user);
}
class AuthError extends AuthState {
final String message;
AuthError(this.message);
}
// auth_cubit.dart
class AuthCubit extends Cubit<AuthState> {
final AuthRepository _repo;
AuthCubit(this._repo) : super(AuthInitial());
Future<void> login(String email, String password) async {
emit(AuthLoading());
try {
final user = await _repo.login(email, password);
emit(AuthAuthenticated(user));
} catch (e) {
emit(AuthError(e.toString()));
}
}
}
// --- RIVERPOD NOTIFIER APPROACH (1 file) ---
// auth_provider.dart
@riverpod
class Auth extends _$Auth {
@override
AsyncValue<User?> build() => const AsyncValue.data(null);
Future<void> login(String email, String password) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(
() => ref.read(authRepositoryProvider).login(email, password),
);
}
}
AsyncValue<T> is a sealed union of data/loading/error built into the framework. You get the same three-state coverage that Cubit requires four hand-written classes to express — at zero extra cost.Dimension 2 — Testability
Both approaches are highly testable, but they differ in setup complexity. Cubit tests use bloc_test's blocTest helper to assert emitted state sequences. Riverpod tests use a ProviderContainer with dependency overrides — no dedicated test library is needed for basic cases.
- BLoC/Cubit:
blocTestreads naturally as a BDD spec; excellent for teams that think in event sequences. Requires thebloc_testdependency. - Riverpod:
ProviderContaineroverrides make injecting fakes trivial. Compile-time safety means fewer runtime surprises in tests. No extra package for basic tests.
Dimension 3 — Team Size and Architecture Enforcement
BLoC's strict event-driven contract is its greatest asset on large teams. Every state transition is an explicit, named event that shows up in code review, logs, and analytics. Riverpod is more flexible, which is an advantage for small teams and a potential liability on large ones (logic can creep into providers).
Decision Matrix
// When to prefer BLoC / Cubit:
// - Team >= 5 developers sharing the same feature area
// - Regulated domain (finance, healthcare) requiring audit trails
// - You already use flutter_bloc and have established patterns
// - Complex event-driven flows (websocket streams, undo/redo)
// When to prefer Riverpod:
// - Solo or small team (<= 4 developers)
// - Greenfield project with no prior BLoC investment
// - You want compile-time safety (code generation via @riverpod)
// - Many small, composable providers rather than large blocs
// - Async dependencies between providers (ref.watch chains)
Dimension 4 — Complexity and Learning Curve
Cubit is the gentlest entry point in the BLoC family. Full BLoC adds the event abstraction. Riverpod's steepest early hurdle is understanding provider types (Provider, StateProvider, FutureProvider, NotifierProvider) and the ref object. Once past that, Riverpod's composition model becomes very intuitive.
emit(), and pattern-matching on state). You can always migrate a Cubit to a Riverpod Notifier incrementally — they map almost 1-to-1 as shown below.Refactor Walkthrough — Cubit to Riverpod Notifier
The following migration shows a CartCubit refactored to a CartNotifier. The logic is identical; only the plumbing changes.
Step-by-Step Migration
// BEFORE — CartCubit
class CartState {
final List<CartItem> items;
final bool isLoading;
const CartState({required this.items, this.isLoading = false});
CartState copyWith({List<CartItem>? items, bool? isLoading}) {
return CartState(
items: items ?? this.items,
isLoading: isLoading ?? this.isLoading,
);
}
}
class CartCubit extends Cubit<CartState> {
CartCubit() : super(const CartState(items: []));
void addItem(CartItem item) {
emit(state.copyWith(items: [...state.items, item]));
}
void removeItem(String itemId) {
emit(state.copyWith(
items: state.items.where((i) => i.id != itemId).toList(),
));
}
double get total =>
state.items.fold(0.0, (sum, item) => sum + item.price);
}
// AFTER — CartNotifier (Riverpod)
class CartState {
final List<CartItem> items;
final bool isLoading;
const CartState({required this.items, this.isLoading = false});
CartState copyWith({List<CartItem>? items, bool? isLoading}) {
return CartState(
items: items ?? this.items,
isLoading: isLoading ?? this.isLoading,
);
}
}
@riverpod
class Cart extends _$Cart {
@override
CartState build() => const CartState(items: []);
void addItem(CartItem item) {
state = state.copyWith(items: [...state.items, item]);
}
void removeItem(String itemId) {
state = state.copyWith(
items: state.items.where((i) => i.id != itemId).toList(),
);
}
double get total =>
state.items.fold(0.0, (sum, item) => sum + item.price);
}
// UI consumption — Riverpod
class CartIcon extends ConsumerWidget {
const CartIcon({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(cartProvider).items.length;
return Badge(label: Text('$count'), child: const Icon(Icons.shopping_cart));
}
}
ref.watch() inside a button's onPressed callback or inside a method — only use ref.read() in response to events. ref.watch() belongs in build() to subscribe to reactive updates.Summary — Decision Cheat Sheet
Use this quick-reference when choosing a solution for a new feature:
- Strict team conventions + audit trail needed → full BLoC with events
- Less boilerplate, still structured → Cubit
- Compile-time safety + composable async dependencies → Riverpod Notifier
- Simple derived/computed values with no mutations → Riverpod
ProviderorFutureProvider - Migrating an existing Cubit incrementally → swap the base class, replace
emit()withstate =, wrap widget withConsumerWidget