Take one ordinary screen: fetch a user from an API and show it. Every app has to handle three outcomes:
- it worked
- it's still loading
- it failed
BLoC and Riverpod both handle all three. The difference is who writes the plumbing.
BLoC: you define every state
With BLoC you model the states yourself and emit them.
sealed class UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState {
UserLoaded(this.user);
final User user;
}
class UserError extends UserState {
UserError(this.message);
final String message;
}
class UserCubit extends Cubit<UserState> {
UserCubit(this.repo) : super(UserLoading());
final UserRepository repo;
Future<void> load() async {
emit(UserLoading());
try {
emit(UserLoaded(await repo.fetchUser()));
} catch (e) {
emit(UserError(e.toString()));
}
}
}
The UI switches on the state:
BlocBuilder<UserCubit, UserState>(
builder: (context, state) => switch (state) {
UserLoading() => const CircularProgressIndicator(),
UserLoaded(:final user) => Text(user.name),
UserError(:final message) => Text(message),
},
)
You wrote the three state classes, the try/catch and the transitions. In return you get full control and an explicit, traceable flow.
Riverpod: AsyncValue wraps it for you
With Riverpod, an async provider wraps its result in AsyncValue automatically.
final userProvider = FutureProvider<User>((ref) {
return ref.watch(userRepositoryProvider).fetchUser();
});
The widget pattern-matches on it:
class UserView extends ConsumerWidget {
const UserView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ref.watch(userProvider).when(
data: (user) => Text(user.name),
loading: () => const CircularProgressIndicator(),
error: (e, _) => TextButton(
onPressed: () => ref.invalidate(userProvider),
child: const Text('Retry'),
),
);
}
}
No state classes, no try/catch. Loading and error states come from the wrapper. Retry is "invalidate the provider".
So which is better?
Neither is wrong. They trade differently:
| BLoC | Riverpod (AsyncValue) | |
|---|---|---|
| Async states | You define them | Provided by AsyncValue
|
| Error handling | Your try/catch | Captured by the provider |
| Boilerplate for request/response screens | More | Less |
| Explicit event flow | Strong | Lighter |
For plain request/response screens, AsyncValue removes a lot of repetition. For complex flows where you want every event and transition spelled out (multi-step forms, payment flows), explicit BLoC states can be easier to reason about and test.
What matters more than the library: always render all three outcomes. A screen that handles only the success case is the real bug, whichever library you use.
Further reading: Riverpod vs BLoC in 2026 on our blog compares the two for clean architecture as well.
Which one does your team use, and why?
Top comments (0)