DEV Community

Cover image for One network request, two models: BLoC states vs Riverpod's AsyncValue
Stacklyn Labs
Stacklyn Labs

Posted on

One network request, two models: BLoC states vs Riverpod's AsyncValue

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;
}
Enter fullscreen mode Exit fullscreen mode
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()));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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),
  },
)
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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'),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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)