Advanced State Management (Bloc & Riverpod)

Choosing the Right Solution: BLoC vs Riverpod in Practice

16 min Lesson 14 of 14

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),
    );
  }
}
Note: Riverpod's 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: blocTest reads naturally as a BDD spec; excellent for teams that think in event sequences. Requires the bloc_test dependency.
  • Riverpod: ProviderContainer overrides 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.

Tip: If your team is new to state management, start with Cubit. Its API surface is small (one class, 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));
  }
}
Warning: Avoid the common mistake of calling 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 Provider or FutureProvider
  • Migrating an existing Cubit incrementally → swap the base class, replace emit() with state =, wrap widget with ConsumerWidget
Key Takeaway: BLoC and Riverpod are complementary philosophies, not adversaries. BLoC optimises for explicitness and traceability; Riverpod optimises for type safety and composability. The best teams understand both, pick one as their primary tool, and migrate thoughtfully when requirements change.