State Management in Flutter with Riverpod: Why We Replaced Provider in New Projects
For a while, Provider was our default answer whenever someone asked how to handle state in a new Flutter project. It's what most of the ecosystem's historical documentation recommends, it builds on InheritedWidget, which anyone coming from plain Flutter already knows, and for simple screens it works without friction. The problem isn't that Provider is broken: it's that as a project grows, it starts demanding things Provider can't give, because its design depends on the widget tree to resolve dependencies — and that dependency gets paid for in testing difficulty, in runtime errors the compiler never catches, and in coupling that becomes hard to undo.
This article walks through the concrete problems that pushed us to adopt Riverpod as the standard for new projects. It wasn't an aesthetic preference — it was a response to failures we kept seeing repeat.
The root problem: Provider depends on BuildContext
Provider exposes its dependencies through the widget tree. To read a value, a widget needs a BuildContext that sits below the corresponding Provider in the tree:
class CartScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cart = context.watch<CartProvider>();
return Text('${cart.total}');
}
}
That looks reasonable until you need to read the same state outside a widget: inside an async callback after an await, inside a service method that has no access to the tree, or inside a unit test that doesn't want to mount a full MaterialApp just to instantiate a provider. The usual workaround with Provider is passing BuildContext down into layers that shouldn't know about it, or capturing it before an await and using it afterward — which produces the classic "Looking up a deactivated widget's ancestor is unsafe" error when the widget unmounts while the async operation is still in flight.
Future<void> confirmPurchase(BuildContext context) async {
final cart = context.read<CartProvider>();
await processPayment(cart.total);
// if the widget that called this is no longer mounted, this blows up:
context.read<NavigationProvider>().goToConfirmation();
}
The error doesn't show up at compile time. It shows up in production, when a user navigates quickly or the connection is slow enough that the await takes long enough for the screen to no longer exist by the time the response arrives. It's exactly the kind of intermittent failure that's hard to reproduce in development and ends up reported as "the app sometimes crashes at checkout," with no further context.
Implicit dependencies: what a provider needs doesn't show up in its signature
With Provider, a ChangeNotifierProvider typically depends on other providers sitting higher up the tree, resolved via ProxyProvider or by reading from the context in the constructor. That dependency doesn't appear in any type: to know what a provider needs you have to read its full implementation, and to know whether two providers are registered in the right order you have to check the widget tree where they're set up, usually a MultiProvider at the app's root.
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProxyProvider<AuthProvider, CartProvider>(
create: (_) => CartProvider(),
update: (_, auth, cart) => cart!..updateUser(auth.userId),
),
],
child: MyApp(),
)
If someone flips the order of those two providers in the MultiProvider, the error surfaces at runtime, usually as a ProviderNotFoundException when trying to resolve a dependency that doesn't yet exist in the tree at that point. Dart's analyzer has no way to catch this before the app runs, because from a type-checking standpoint, ChangeNotifierProxyProvider never expresses that it needs AuthProvider available above it — that fact lives entirely in the order of a list.
Riverpod inverts this. A provider declares its dependencies by calling other providers directly, without going through the widget tree, and the dependency graph is resolved once, in the code itself, not in registration order:
final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
final cartProvider = NotifierProvider<CartNotifier, CartState>(
CartNotifier.new,
);
class CartNotifier extends Notifier<CartState> {
@override
CartState build() {
final auth = ref.watch(authProvider);
return CartState.initial(userId: auth.userId);
}
}
cartProvider explicitly declares, in its own build, that it depends on authProvider. There's no need to register anything in a particular order or wrap the app in a nested tree of providers: Riverpod resolves the dependency the first time something reads cartProvider, regardless of which file or in what order the two providers were defined. And if authProvider changes, cartProvider rebuilds automatically, because ref.watch establishes that relationship explicitly, with types the analyzer can actually verify.
Testing: providers without a widget tree
The difference that matters most day to day is testing. Testing a Provider ChangeNotifierProvider in isolation, without spinning up a widget tree, is awkward because the pattern is designed to live inside a BuildContext. The usual approach is wrapping the test in a MaterialApp with a Builder, mounting the widget with flutter_test, and triggering the logic through simulated UI interactions — even when all you actually want to test is the provider's logic, not any widget.
testWidgets('cart calculates the total correctly', (tester) async {
await tester.pumpWidget(
ChangeNotifierProvider(
create: (_) => CartProvider(),
child: MaterialApp(home: Builder(builder: (context) {
return ElevatedButton(
onPressed: () => context.read<CartProvider>().add(product),
child: Text('add'),
);
})),
),
);
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
// ...
});
That's a widget test standing in for what is really pure business logic. With Riverpod, a Notifier is a plain Dart class you can instantiate, test, and discard without any widget involved, using a ProviderContainer:
test('cart calculates the total correctly', () {
final container = ProviderContainer();
addTearDown(container.dispose);
container.read(cartProvider.notifier).add(product);
expect(container.read(cartProvider).total, 25000);
});
This is a pure unit test, it runs in milliseconds, it doesn't depend on flutter_test or mounting anything visual, and it can run in a plain Dart package with no Flutter SDK dependency if the logic is properly separated. When a team migrates a large project to Riverpod, this is usually the change with the biggest impact on how fast the test suite runs: providers that previously demanded a full widget test become direct unit tests against the Notifier class.
Migrating a real provider, step by step
Here's a typical authentication ChangeNotifierProvider migrated to Riverpod in full, not just the abstract pattern.
Before, with Provider:
class AuthProvider extends ChangeNotifier {
User? _user;
bool _loading = false;
User? get user => _user;
bool get loading => _loading;
Future<void> login(String email, String password) async {
_loading = true;
notifyListeners();
try {
_user = await authRepository.login(email, password);
} finally {
_loading = false;
notifyListeners();
}
}
void logout() {
_user = null;
notifyListeners();
}
}
The UI needs context.watch to rebuild and context.read inside callbacks:
class LoginButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final loading = context.watch<AuthProvider>().loading;
return ElevatedButton(
onPressed: loading
? null
: () => context.read<AuthProvider>().login(email, password),
child: loading ? CircularProgressIndicator() : Text('Log in'),
);
}
}
After, with Riverpod, the state is modeled explicitly instead of living as two loose variables, and the Notifier doesn't rely on manually calling notifyListeners:
sealed class AuthState {
const AuthState();
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final User user;
const AuthAuthenticated(this.user);
}
class AuthError extends AuthState {
final String message;
const AuthError(this.message);
}
class AuthNotifier extends Notifier<AuthState> {
@override
AuthState build() => AuthInitial();
Future<void> login(String email, String password) async {
state = AuthLoading();
try {
final user = await ref.read(authRepositoryProvider).login(email, password);
state = AuthAuthenticated(user);
} catch (e) {
state = AuthError(e.toString());
}
}
void logout() => state = AuthInitial();
}
final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
And in the UI, with ConsumerWidget instead of StatelessWidget:
class LoginButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(authProvider);
final loading = state is AuthLoading;
return ElevatedButton(
onPressed: loading
? null
: () => ref.read(authProvider.notifier).login(email, password),
child: loading ? CircularProgressIndicator() : Text('Log in'),
);
}
}
The change isn't just mechanical. Modeling state as a sealed hierarchy (AuthInitial, AuthLoading, AuthAuthenticated, AuthError) instead of two booleans and a nullable field forces the UI to handle every case explicitly, and Dart's analyzer flags an error if a switch over AuthState leaves a case unhandled. With Provider, that same state almost always ends up represented as combinations of loading, user, and some optional error field — which allows impossible states like loading == true and user != null at the same time, with nothing in the type system stopping it.
Where Provider is still enough
We don't migrate everything just to migrate it. For a small app with two or three screens and state that never crosses more than one widget level, Provider remains a reasonable choice: the learning curve is smaller and the package carries fewer dependencies. Switching to Riverpod pays off once one of these signals shows up: state needs to be read outside the widget tree, dependencies between providers become hard to order correctly, or the test suite starts depending on mounting widgets just to test logic that has nothing visual about it.
On new Flutter projects, those signals almost always show up before the first delivery, which is why Riverpod is our default starting point today rather than a migration we put off until the pain becomes obvious.