S

super-dev — full-stack

@super-dev.app · Full-stack · ★ open to work
12.8k subscribers·47 videos·Active since 2017

Full-stack .NET / Angular dev with a DevOps streak on Azure Cloud. Also fluent in Flutter + Firebase. I build products, ship them, and keep them healthy.

Back to articles
FLUTTER
FLUTTER

A testable Flutter architecture with Riverpod

Article 2 of 3 — Flutter in production
Offline-first, Riverpod, a Melos monorepo: serious mobile, not a prototype.

A Flutter app that grows always ends up asking the same question: where does state live, and how do you test it without spinning up a widget? setState mixes logic and UI in the same class; InheritedWidget propagates data well but says nothing about how it's created or swapped out in a test. Riverpod answers both: a dependency-injection container that produces reactive values, independent of the widget tree.

Providers and notifiers

A Provider exposes a value; a Notifier exposes a mutable value along with the logic that drives it. With code generation ( riverpod_generator ), you annotate a class and build() returns the initial state:

Dart
1@riverpod
2class Counter extends _$Counter {
3 @override
4 int build() => 0;
5
6 void increment() => state = state + 1;
7}
8
9@riverpod
10Future<User> currentUser(CurrentUserRef ref) {
11 final api = ref.watch(apiClientProvider);
12
13 return api.fetchMe();
14}

The ref.watch inside a provider creates a dependency : if apiClientProvider changes, currentUser recomputes automatically. This dependency graph is what replaces cascading manual setState calls.

Separating UI from logic

The golden rule: a widget holds no business logic. It reads state and calls methods. All the machinery lives in the notifier, testable without a WidgetTester :

Dart
1class CounterView extends ConsumerWidget {
2 const CounterView({super.key});
3
4 @override
5 Widget build(BuildContext context, WidgetRef ref) {
6 final count = ref.watch(counterProvider);
7
8 return Text('$count');
9 }
10}

On the async read side, ref.watch(currentUserProvider) returns an AsyncValue , whose .when(data:, loading:, error:) covers all three states without a stray `isLoading` boolean .

Dependency injection and test overrides

This is where Riverpod beats InheritedWidget : every provider is overridable when the ProviderContainer is built. In a test, you inject a fake API client without touching production code:

Dart
1test('loads the current user', () async {
2 final container = ProviderContainer(
3 overrides: [
4 apiClientProvider.overrideWithValue(FakeApiClient()),
5 ],
6 );
7 addTearDown(container.dispose);
8
9 final user = await container.read(currentUserProvider.future);
10 expect(user.name, 'Ada');
11});

No global mock, no singleton to reset between tests: each container is isolated, and addTearDown guarantees it's disposed. The Riverpod testing docs cover the pump and listener patterns in detail.

Why not setState or InheritedWidget

setState rebuilds the whole State and welds logic to the UI — impossible to test without rendering the widget. InheritedWidget shares a value but forces you to hand-write updateShouldNotify , handles neither async nor swapping, and leaks the moment you touch BuildContext . Riverpod moves state out of the tree , makes it lazy, memoized and auto-disposed ( autoDispose ), and turns overriding into the normal path for tests.

Good Flutter architecture isn't measured by the number of providers, but by this: can you test the logic without ever mounting a widget ? With Riverpod, the answer is yes by design.
super-dev — portfolio.app
// More in FLUTTER
Offline-first sync in Flutter + Firebase
9 min • 2.2k reads
A Flutter monorepo with Melos
6 min • 1.2k reads