Skip to main content

Command Palette

Search for a command to run...

Riverpod from the Perspective of a Long-Term Bloc User

Updated
β€’11 min read
Y

Google Developer Expert in Flutter & Dart πŸš€| Mobile App Development πŸ“±| Tech Advocate 🌐| Women Who Code KL Director

I've been using Bloc for years. It was my go-to state management solution, and I was comfortable with it. Events go in, states come out. Simple, predictable, testable. So when people kept telling me to try Riverpod, I resisted. Why fix what isn't broken?

But after finally trying it out on a recent project, I learned that Riverpod isn't just "another state management solution", it's a completely different way of thinking. Here's what I found as a Bloc user.

The Mental Model Shift: From Events to Reactivity

This was the biggest adjustment for me.

In Bloc, you think in terms of user actions:

// User taps button β†’ dispatch event β†’ state changes
context.read<CounterBloc>().add(CounterIncremented());

In Riverpod, you think in terms of reactive values:

// Just change the value
ref.read(counterProvider.notifier).increment();

At first, this felt wrong. Where are my events? How do I know what happened? But then it clicked: Riverpod removes the middleman. You don't need an event class for every single action. You just call methods on notifiers.

What I Thought vs. What I Found

I thought: "Without events, I'll lose clarity about what's happening in my app."

I found: Most events in Bloc are just ceremony. Do you really need a CounterIncremented event class? Or a LoadingStarted event? Riverpod lets you skip that boilerplate.

But Bloc has this too: If you use Cubit instead of Bloc, you get the same direct state modification without events. So this isn't really Riverpod vs Bloc, it's more about choosing the right tool. Bloc gives you the choice between explicit events (Bloc) and direct methods (Cubit). Riverpod only offers the Cubit-style approach.

Architecture: Similar to Cubits

If you've used Cubits (Bloc without events), Riverpod will feel familiar.

Here's a typical Bloc setup:

// Bloc
class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<CounterIncremented>((event, emit) => emit(state + 1));
    on<CounterDecremented>((event, emit) => emit(state - 1));
  }
}

// Somewhere in your widget tree
BlocProvider(
  create: (context) => CounterBloc(),
  child: MyApp(),
)

Here's the Riverpod equivalent (as of version 3.0):

class CounterNotifier extends Notifier<int> {
  @override
  int build() => 0;

  void increment() => state++;
  void decrement() => state--;
}

final counterProvider = NotifierProvider<CounterNotifier, int>(
  CounterNotifier.new,
);

// No provider widget needed in your tree

What I noticed: No more provider widgets cluttering my widget tree. Providers are global by default. You can still override them for testing.

Bloc's approach: Bloc requires provider widgets in your tree, which some see as clutter. However, this has benefits, it makes the dependency tree explicit and visible. You can see exactly where each Bloc is provided. The BlocProvider also handles disposal automatically when the widget is removed. With Riverpod, providers are global, which is cleaner but less explicit. You can achieve scoped providers in Riverpod using ProviderScope overrides, but it's less common.

Important note about Riverpod 3.0: As of Riverpod 3.0, StateNotifierProvider and StateProvider are now considered "legacy" providers. They still work but require importing from package:flutter_riverpod/legacy.dart. The recommended approach is NotifierProvider and AsyncNotifierProvider, which I'm showing in these examples.

Dependencies: Different Approach

Here's where Riverpod differs significantly. In Bloc, injecting dependencies is manual and requires you to pass them through the widget tree:

class UserBloc extends Bloc<UserEvent, UserState> {
  final UserRepository repository;

  UserBloc({required this.repository}) : super(UserInitial());

  // ... use repository
}

// In your app, using RepositoryProvider (the official Bloc way)
RepositoryProvider(
  create: (context) => UserRepository(),
  child: BlocProvider(
    create: (context) => UserBloc(
      repository: context.read<UserRepository>(),
    ),
    child: MyWidget(),
  ),
)

In Riverpod, dependencies are automatic:

final userRepositoryProvider = Provider((ref) => UserRepository());

class UserNotifier extends Notifier<AsyncValue<User>> {
  @override
  AsyncValue<User> build() {
    return const AsyncValue.loading();
  }

  Future<void> loadUser() async {
    // Just read the dependency you need
    final repository = ref.read(userRepositoryProvider);
    state = await AsyncValue.guard(() => repository.getUser());
  }
}

What I noticed: Dependencies are tracked automatically. If userRepositoryProvider rebuilds, any provider that depends on it rebuilds too.

Bloc's approach: Bloc provides RepositoryProvider (part of the flutter_bloc package) to inject dependencies through the widget tree:

MultiRepositoryProvider(
  providers: [
    RepositoryProvider(create: (context) => UserRepository()),
    RepositoryProvider(create: (context) => AuthRepository()),
  ],
  child: MultiBlocProvider(
    providers: [
      BlocProvider(
        create: (context) => UserBloc(
          repository: context.read<UserRepository>(),
        ),
      ),
      BlocProvider(
        create: (context) => AuthBloc(
          repository: context.read<AuthRepository>(),
        ),
      ),
    ],
    child: MyApp(),
  ),
)

This works and keeps everything explicit in the widget tree. However, there are some limitations:

  1. No automatic reactivity: If a repository needs to be recreated, Blocs that depend on it won't automatically rebuild, you'd need to manually handle this

  2. More boilerplate: You need to wire up RepositoryProvider β†’ BlocProvider β†’ Widget for each dependency

  3. Tree-based access only: Repositories are only available below their RepositoryProvider in the widget tree

Alternative: Service Locators: Some Bloc users prefer service locator patterns like get_it:

// Setup once at app startup
final getIt = GetIt.instance;
getIt.registerSingleton<UserRepository>(UserRepository());

// Use anywhere
BlocProvider(
  create: (context) => UserBloc(
    repository: getIt<UserRepository>(),
  ),
)

This avoids nesting providers but loses the widget tree context and automatic disposal. It's also a different dependency injection philosophy (service locator vs dependency injection container).

Riverpod's difference: Riverpod's approach is built-in and reactive. Dependencies automatically track changes, rebuild dependents when needed, and work anywhere without widget tree constraints. You don't need extra packages or decide between tree-based vs global approaches, it just works.

Async State: AsyncValue Reduces Boilerplate

In Bloc, handling loading/error/success states means creating state classes:

abstract class UserState {}

class UserInitial extends UserState {}

class UserLoading extends UserState {}

class UserLoaded extends UserState {
  final User user;
  UserLoaded(this.user);
}

class UserError extends UserState {
  final String message;
  UserError(this.message);
}

// In your widget
BlocBuilder<UserBloc, UserState>(
  builder: (context, state) {
    if (state is UserLoading) return CircularProgressIndicator();
    if (state is UserError) return Text(state.message);
    if (state is UserLoaded) return Text(state.user.name);
    return SizedBox();
  },
)

In Riverpod, AsyncValue does this for you:

final userProvider = FutureProvider((ref) async {
  return await ref.read(userRepositoryProvider).getUser();
});

// In your widget
ref.watch(userProvider).when(
  loading: () => CircularProgressIndicator(),
  error: (error, stack) => Text('Error: $error'),
  data: (user) => Text(user.name),
);

What I noticed: AsyncValue eliminates the boilerplate. No more creating four state classes for every async operation.

Workaround with Bloc: You can reduce boilerplate in Bloc using generic base classes or packages. Some teams use patterns like this:

// Generic async state
class AsyncState<T> {
  final bool isLoading;
  final T? data;
  final String? error;

  AsyncState({this.isLoading = false, this.data, this.error});

  AsyncState<T> copyWith({bool? isLoading, T? data, String? error}) {
    return AsyncState(
      isLoading: isLoading ?? this.isLoading,
      data: data ?? this.data,
      error: error ?? this.error,
    );
  }
}

class UserBloc extends Bloc<UserEvent, AsyncState<User>> {
  UserBloc() : super(AsyncState(isLoading: true));
  // ...
}

Testing: Different Approach

In Bloc, testing meant mocking and pumping events:

blocTest<CounterBloc, int>(
  'increments counter',
  build: () => CounterBloc(),
  act: (bloc) => bloc.add(CounterIncremented()),
  expect: () => [1],
);

In Riverpod (3.0+), you use ProviderContainer.test():

test('increments counter', () {
  // ProviderContainer.test() automatically disposes after the test
  final container = ProviderContainer.test();

  expect(container.read(counterProvider), 0);
  container.read(counterProvider.notifier).increment();
  expect(container.read(counterProvider), 1);

  // No need to manually dispose - test() handles it
});

For dependencies:

test('loads user', () async {
  final container = ProviderContainer.test(
    overrides: [
      userRepositoryProvider.overrideWithValue(MockUserRepository()),
    ],
  );

  final user = await container.read(userProvider.future);
  expect(user.name, 'Test User');
});

What I noticed: Testing is more direct. You're testing the actual provider logic, not orchestrating events and states. The new ProviderContainer.test() utility in 3.0 handles disposal automatically.

Bloc's advantage: The bloc_test package makes testing Blocs straightforward. You get to test the full event β†’ state transformation pipeline, and you can assert on state streams. This is actually more comprehensive than basic Riverpod tests:

blocTest<UserBloc, UserState>(
  'loads user successfully',
  build: () => UserBloc(repository: mockRepository),
  act: (bloc) => bloc.add(UserLoadRequested()),
  expect: () => [
    UserLoading(),
    UserLoaded(testUser),
  ],
  verify: (_) {
    verify(() => mockRepository.getUser()).called(1);
  },
);

The bloc_test package's expect parameter lets you assert on the entire sequence of states, which catches more bugs than testing the final state alone. In Riverpod, you'd need to manually listen to state changes to get similar verification. However, Riverpod's override system is simpler for dependency injection in tests, no need for constructor injection, just override the provider directly.

The Learning Curve: It Takes Time

Riverpod has a steeper learning curve. Here's what tripped me up:

1. Too Many Provider Types

Even with Riverpod 3.0 moving legacy providers out, you still have: Provider, FutureProvider, StreamProvider, NotifierProvider, AsyncNotifierProvider. It's confusing at first.

Bloc's simplicity: Bloc has two types: Bloc and Cubit. That's it. The choice is clear: use Bloc if you want events, Cubit if you don't. This is easier when onboarding new developers.

2. ref.watch vs ref.read vs ref.listen

  • ref.watch: Rebuilds when value changes (use in build)

  • ref.read: One-time read (use in callbacks)

  • ref.listen: Side effects (use for navigation, snackbars)

Bloc's equivalent: Bloc has similar concepts but with different names:

  • BlocBuilder: Like ref.watch, rebuilds on state changes

  • context.read(): Like ref.read, one-time access

  • BlocListener: Like ref.listen, for side effects

So the concepts exist in both, just with different APIs.

3. Code Generation Confusion

Riverpod has two styles: runtime and code generation. The docs push code generation with the @riverpod annotation, but it adds complexity. I started with runtime and was fine. But it may be interesting for reducing the boilerplate further.

Bloc doesn't have this problem: There's no code generation debate with Bloc. You write your code, and it runs. This is simpler. Riverpod's code generation offers benefits (type safety, less boilerplate) but adds build complexity with build_runner. With Bloc, what you write is what runs, no build step to configure.

When to Use Bloc vs Riverpod

After using both, here's my take:

Use Bloc when:

  • You want explicit events for debugging (event logs are useful)

  • Your team is already comfortable with Bloc

  • You prefer simpler tooling (no code generation decisions)

  • You want to see the full history of state changes in tests

  • You like having dependencies explicit in the widget tree

Use Riverpod when:

  • You want less boilerplate (though Cubit narrows this gap)

  • You need automatic dependency management and reactivity

  • You want better performance with granular rebuilds (though Bloc can achieve this with proper structure)

  • You prefer global providers over widget-tree-based injection

  • You want dependencies to automatically trigger rebuilds when they change

What Bloc Does Better

Bloc has some advantages:

  1. Event logs: Seeing every event that flows through your app is useful for debugging. Riverpod has no equivalent, you'd need to add logging manually to each notifier method.

  2. Explicit transitions: on<Event> makes it clear what triggers what. In Riverpod, methods can be called from anywhere, making it harder to trace data flow.

  3. BlocObserver: One place to log all events and state changes across your entire app. Riverpod has ProviderObserver, but it's less informative, it only tells you which providers changed, not why or what methods were called.

  4. Mature ecosystem: More tutorials, more Stack Overflow answers, more proven patterns in production apps.

  5. Testing streams: bloc_test can verify the entire sequence of state emissions, catching race conditions and intermediate states. Riverpod tests typically only check final states.

  6. Simpler mental model: Two choices (Bloc or Cubit), clear documentation, no code generation decisions.

  7. Explicit dependency tree: With RepositoryProvider and BlocProvider, you can see exactly where dependencies are provided in the widget tree. This makes the app structure more visible.

What Riverpod Does Better

  1. Less code: Even compared to Cubit, Riverpod typically requires fewer lines. No provider widgets, no disposal logic.

  2. No provider widgets: My widget tree is cleaner. Though Bloc's explicit tree does make dependencies more visible.

  3. Automatic dependencies: No manual injection needed. Bloc's RepositoryProvider works but requires more setup and nesting.

  4. AsyncValue: Built-in loading/error/data handling that's more convenient than Bloc's patterns. Though Bloc can build similar abstractions.

  5. Flexibility: Easy to split and combine providers. You can compose complex state from simple providers.

  6. Reactive dependencies: When a dependency changes, dependent providers automatically rebuild. In Bloc, you'd need to manually recreate the Bloc or emit new states.

  7. Performance by default: Granular rebuilds out of the box. Bloc can achieve this with BlocSelector but requires more manual optimization.

  8. Simpler dependency testing: Just override providers in tests. No need to mock constructors or pass dependencies through multiple layers.

My Migration Strategy

If you're considering switching, here's what worked for me:

  1. Start small: Convert one feature, not the whole app

  2. Learn NotifierProvider first: It's closest to Cubit and is the recommended approach in Riverpod 3.0

  3. Don't use code generation initially: Add it later if needed

  4. Read the docs: Riverpod's docs are dense but thorough

  5. Accept you'll lose some things: Event logs and BlocObserver are useful. Decide if you can live without them.

  6. Be aware of version changes: If using older tutorials, know that Riverpod 3.0 moved StateNotifierProvider to legacy and introduced new testing utilities like ProviderContainer.test()

Final Thoughts

Would I choose Riverpod for my next project? Yes.

Would I rewrite all my Bloc projects? No.

Riverpod isn't strictly better than Bloc, it's different. It trades explicit events for less boilerplate. It trades centralized event logs for automatic dependency management. It trades simplicity for power.

The key takeaway: Most of Riverpod's advantages over Bloc are really advantages over Bloc's event pattern specifically. If you compare Riverpod to Cubit (Bloc without events), the differences narrow significantly. Both let you modify state directly, both have simple APIs, both work well for most use cases.

Where Riverpod wins is dependency management and composition. Where Bloc wins is debuggability and explicitness.

As a long-term Bloc user, learning Riverpod changed how I think about state management. Even if you stick with Bloc, understanding Riverpod's reactive approach is useful.

The Flutter ecosystem is better for having both options.