Week 8: Riverpod: Providers, Notifiers & Compile-Time Safety

Riverpod was built by Provider's own author to fix Provider's sharpest edges — a context-independent API, compile-time detection of missing providers, and easier testing. This week rebuilds last week's app on Riverpod, and closes the phase with a clear framework for choosing between Provider, Riverpod, and Bloc.

Phase 4 of 8 Week 8 of 20 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Define and read providers with Riverpod, without needing a BuildContext
  • Model mutable state with a Notifier and understand what compile-time safety Riverpod adds over Provider
  • Test provider logic in isolation, without building any widget tree
  • Choose between Provider, Riverpod, and Bloc for a given project

1. Providers Without BuildContext

terminal
flutter pub add flutter_riverpod
main.dart — wrapping the app once, at the root
void main() {
  runApp(const ProviderScope(child: MyApp()));
}
a simple provider
final greetingProvider = Provider<String>((ref) => 'Hello, Flutter!');

class GreetingText extends ConsumerWidget {
  const GreetingText({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final greeting = ref.watch(greetingProvider);
    return Text(greeting);
  }
}

Providers are defined as top-level, global variables — but they're not global mutable state in the dangerous sense; ProviderScope gives each one a real, overridable, testable instance, which is precisely what removing the BuildContext dependency makes possible.

2. Modeling Mutable State with Notifier

a Riverpod NotifierProvider
class CartNotifier extends Notifier<List<String>> {
  @override
  List<String> build() => []; // initial state

  void add(String item) {
    state = [...state, item]; // reassigning state triggers listeners
  }

  void remove(String item) {
    state = state.where((i) => i != item).toList();
  }
}

final cartProvider = NotifierProvider<CartNotifier, List<String>>(CartNotifier.new);
reading and mutating it from a widget
class CartBadge extends ConsumerWidget {
  const CartBadge({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final items = ref.watch(cartProvider);
    return Badge(label: Text('${items.length}'));
  }
}

// elsewhere, mutating:
ref.read(cartProvider.notifier).add('Widget');

Reassigning state (rather than mutating it in place, as [...state, item] does above) is deliberate — Riverpod compares the old and new state to decide whether to notify listeners, which requires a genuinely new value, not an in-place mutation of the same list.

3. Compile-Time Safety & Testing

Provider's context.watch<CartModel>() fails at runtime, with a thrown exception, if no matching provider exists above that widget in the tree. Riverpod's providers are strongly typed top-level references — a typo or a missing provider is caught by the analyzer, before the app ever runs.

testing provider logic with zero widgets
test('adding an item increases cart length', () {
  final container = ProviderContainer();
  addTearDown(container.dispose);

  container.read(cartProvider.notifier).add('Widget');

  expect(container.read(cartProvider).length, 1);
});

ProviderContainer gives direct access to provider state with no BuildContext and no widget tree at all — a real, meaningful advantage for testing business logic in isolation, ahead of Week 16's testing work, without needing WidgetTester just to verify a notifier's behavior.

4. Provider vs. Riverpod vs. Bloc

  • Provider — a solid choice for a smaller app, or a team already deeply familiar with it; simplest mental model, tied to BuildContext.
  • Riverpod — the default recommendation going forward in this course: compile-time safety, easy testing, no context dependency, and it's what the rest of this curriculum builds on.
  • Bloc — worth reaching for when a team wants a strict, explicit separation between events (inputs) and states (outputs), often for larger teams or apps with complex, auditable business logic where that ceremony pays for itself.

All three solve the same underlying problem from Week 6 — none is "more correct" in the abstract; the right choice depends on team size, how much explicit structure the app's state logic actually needs, and (realistically) what a team already knows.

5. Hands-on Exercise

Hands-on

Migrate the cart app to Riverpod, with a real unit test

Rebuild Week 7's Provider-based cart on Riverpod, and test the notifier without any widgets.

Requirements:

  1. A CartNotifier (Notifier<List<String>>) with add and remove, wrapped in NotifierProvider.
  2. Wrap the app in ProviderScope and rebuild the cart badge and product screen as ConsumerWidgets using ref.watch/ref.read.
  3. A unit test using ProviderContainer that adds and removes items and asserts the final cart contents, with no widget tree involved at all.
  4. Deliberately reference a provider that doesn't exist (a typo'd name) and confirm the analyzer flags it before you even run the app — contrast this with Provider's runtime failure mode from this week's third section.
  5. A short comment comparing your experience testing the Riverpod version against how you'd have to test the equivalent Provider-based ChangeNotifier (which needs a widget tree, or at least a mocked context, to exercise realistically).
Hint

If state = ... inside your notifier doesn't seem to trigger a rebuild anywhere watching it, check that you're assigning a genuinely new value (a new list via spread, [...state, item]) rather than mutating the existing list in place (state.add(item)) — Riverpod's default equality check won't detect an in-place mutation as a change.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why can Riverpod providers be defined as top-level variables without that being dangerous global mutable state?

ProviderScope gives each provider a real, scoped instance per app (or per test, via ProviderContainer) rather than the top-level declaration itself holding the actual mutable data — the top-level variable is just a typed reference/key used to look up that scoped instance, which is also what makes providers overridable in tests without touching global state.

Q2

What class of bug does Riverpod's compile-time safety catch that Provider's context.watch<T>() cannot, and when does each actually surface?

A missing or mistyped provider reference — Riverpod's typed top-level provider objects mean the analyzer catches a reference to a provider that doesn't exist (or a type mismatch) before the app ever runs. Provider's context-based lookup can't be checked until runtime, so the identical class of mistake only surfaces as a thrown exception when that specific widget actually tries to build.

Q3

Why does ProviderContainer make testing a notifier's logic meaningfully easier than testing the equivalent Provider-based ChangeNotifier?

It gives direct read/write access to provider state with no BuildContext and no widget tree required at all — a plain unit test can exercise the notifier's methods and assert on its state directly. Testing a context-dependent ChangeNotifier realistically requires at least a minimal widget tree (or a mocked context) just to reach it, which is more setup for the same underlying test.

Q4

Per this week's framework, what would specifically justify choosing Bloc over Riverpod for a project, rather than defaulting to Riverpod?

A team or project that wants a strict, explicit, auditable separation between events (what happened) and states (what the UI should show) — Bloc enforces that structure directly, which is valuable for larger teams or complex business logic where that ceremony and traceability pays for itself. For most other cases, Riverpod's lighter structure is the better default, per this week's recommendation.