A testable Flutter architecture with Riverpod
setState welds the logic to the UI in the same class. InheritedWidget does propagate the data but says nothing about its creation or its replacement in tests. Riverpod addresses both problems at once: it's a dependency injection container that produces reactive values, declared outside the widget tree and linked together by a typed graph.
Three families of providers
provider s, and three forms cover nearly all needs. A Provider returns an immutable value (an HTTP client, a repository, a configuration). A NotifierProvider pairs a mutable state with the logic that evolves it. An AsyncNotifierProvider does the same thing when the initial state is asynchronous: a network call, a database read.
@riverpod , the generator produces the provider, and build() returns the starting state.
todoRepositoryProvider , counterProvider , todoListProvider . It's this identifier that the rest of the app consumes, never the class directly.
The dependency graph
ref.watch inside a provider creates a dependency . todoListProvider watches todoRepositoryProvider , which watches httpClientProvider . If one changes, everything that depends on it recomputes, with no manual cascading wiring. It's a directed graph, and since each provider has a return type known at compile time, a wiring mistake becomes a compile error rather than a runtime crash.
ref comes in three usages that must be distinguished. ref.watch subscribes: it belongs inside a build() , of a widget or a provider, to react to change. ref.read reads once, with no subscription: use it in an event handler, to trigger a method. ref.listen registers a side effect (showing an error, navigating) without rebuilding.
.notifier gives you the object that carries the methods. Mixing up the two, for instance watching a notifier inside a callback, remains the most common mistake among newcomers.
Reading async state without a stray boolean
AsyncNotifierProvider doesn't return a raw Future but an AsyncValue , a sum type that encodes the three possible states: data, loading, error. .when(data:, loading:, error:) forces you to handle all three, which removes the isLoading boolean you forget to reset to false .
AsyncValue.guard , which captures the exception and stores it in an AsyncError instead of letting it propagate.
AsyncValue , and its .when shows the spinner while reloading, then the list, then an error message if the write failed.
Global variables that aren't
final at file level, which looks like a global variable. The resemblance stops at first glance. The identifier serves as a key; the actual state lives in a ProviderContainer . Two containers, two tests or two windows, share no state even if they read the same provider.
@riverpod makes the provider autoDispose by default: as soon as nothing is listening to it anymore, its state is destroyed, then lazily recreated on the next read. To keep a state alive, a cache or a session, you annotate it @Riverpod(keepAlive: true) . And ref.onDispose releases a resource, for instance closing a socket, at cleanup time.
The override as the normal path for testing
InheritedWidget : every provider is replaceable when the container is mounted. In tests, you inject a fake repository without touching the production code.
ProviderContainer is isolated, and addTearDown guarantees its release. For a widget test, the same mechanism goes through ProviderScope(overrides: [...]) at the top of the tree. The Riverpod testing documentation details the pump and listening patterns.
Splitting up a feature
Provider s. The logic layer holds the notifiers, which watch these repositories. The presentation layer contains nothing but ConsumerWidget s that watch the notifiers and call their methods. A widget carries no business logic: it reads and it delegates.
ref.watch instead of a constructor. Testing follows the same boundary: you replace the layer below with an override, and verify the one above, in isolation.
A Flutter architecture is measured by its testability: the logic must be verifiable without mounting a single widget. Riverpod places state outside the tree and makes overriding a first- class mechanism, which makes this verification possible by default.