Riverpod Simplified: Lessons Learned From 4 Years of Development


Introduction
In the Flutter ecosystem, state management is always a hot topic. Riverpod gets a lot of hate for having weird syntax, unclear usage rules, and a tendency to create a "spider web" of providers. The last point stems from the previous one: unclear usage.
After using Riverpod extensively for four years, I've developed a set of practical lessons that have significantly improved my development experience and simplified my application architecture, which I want to share with you in order to simplify using Riverpod once and for all.
Let’s get to it.
Understanding the Core Distinction: Providers vs. Notifiers
The first and perhaps most important principle is to clearly understand the distinction between providers and notifiers:
Providers are for dependency injection. They should be used to provide dependencies throughout your application, making your code more modular and testable. Providers create a clean way to access services, repositories, and other dependencies without tight coupling.
Notifiers are for state management. Classes like AsyncNotifier
and Notifier
, and other notifier variants are specifically designed to manage state. They encapsulate the logic for how the state changes over time and in response to events.
By respecting this distinction, your codebase becomes more organized and easier to reason about. Each component has a clear responsibility, preventing the "spaghetti code" that often plagues state management solutions.
The Critical Difference: ref.watch
vs. ref.read
A common source of unexpected rebuilds and performance issues in Riverpod applications is the misuse of ref.watch
and ref.read
:
Use ref.watch
only inside widgets. The watch
method creates a subscription to a provider, causing rebuilds whenever the watched provider changes. This is exactly what you want in widgets, but can lead to cascading, hard-to-debug rebuilds elsewhere.
Use ref.read
for dependency injection. When you just need to get a value once (like in a service or repository), use ref.read
. This retrieves the current value without setting up a subscription, preventing silent rebuild chains.
For example:
// GOOD: In a widget, watching for changes
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userProvider);
return Text(user.name);
}
// GOOD: In a service/repository, reading once
class AuthService {
AuthService(this.ref);
final Ref ref;
Future<void> login() async {
final apiClient = ref.read(apiClientProvider);
await apiClient.login();
}
}
Debugging the Spider Web: onDispose and Observer
When faced with the infamous "spider web" of rebuilding providers, two tools are invaluable:
Use onDispose
to track the provider lifecycle. Adding logging in to the onDispose
method can help you understand when providers are being disposed of, which can reveal unexpected behavior:
final myProvider = Provider((ref) {
ref.onDispose(() {
print('myProvider was disposed');
});
return MyService();
});
Leverage ProviderObserver
for comprehensive debugging. By implementing a custom ProviderObserver
, you can log all provider changes, creations, and disposals:
class LoggingObserver extends ProviderObserver {
@override
void didUpdateProvider(
ProviderBase provider,
Object? previousValue,
Object? newValue,
ProviderContainer container,
) {
print('''
{
"provider": "${provider.name ?? provider.runtimeType}",
"newValue": "$newValue"
}
''');
}
}
// Then in your app:
void main() {
runApp(
ProviderScope(
observers: [LoggingObserver()],
child: MyApp(),
),
);
}
These tools provide visibility into Riverpod's internal workings, making it much easier to identify the source of unexpected rebuilds.
Useful Exceptions to the Rules
While the principles above serve as a solid foundation, there are exceptions worth noting:
StateNotifier: Should Be Deprecated, But Still Useful
StateNotifier
should technically be deprecated in favor of the newer Notifier
class. However, it remains extremely useful in certain scenarios due to its simplicity and clear contract:
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier();
});
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);
void increment() => state = state + 1;
}
I recommend swapping StateNotifier
with Notifier
for most use cases:
final counterProvider = NotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier();
});
class CounterNotifier extends Notifier<int> {
@override
int build() {
return 0;
}
void increment() {
state = state + 1;
}
}
The newer notifiers provide better integration with other Riverpod features and a more consistent API. However, don't be afraid to use StateNotifier
when its simplicity serves your specific case better - pragmatism should win over strict adherence to the latest patterns.
Future/Stream Providers vs. AsyncNotifier
FutureProvider
and StreamProvider
can be very useful for handling asynchronous data in simple scenarios. They automatically handle loading and error states:
final userProvider = FutureProvider.autoDispose((ref) async {
final userId = ref.watch(userIdProvider);
return await ref.watch(userRepositoryProvider).fetchUser(userId);
});
While FutureProvider
and StreamProvider
are convenient for quick implementations, AsyncNotifier
scales better as your application inevitably grows in complexity.
Conclusion
Riverpod's power comes with complexity, but these lessons learned from experience can help you tame that complexity:
Respect the distinction between providers (for DI) and notifiers (for state)
Use
ref.watch
in widgets,ref.read
everywhere elseLeverage
onDispose
andProviderObserver
for debuggingPrefer
AsyncNotifier
overFutureProvider
/StreamProvider
for better codebase consistencyConsider using
Notifier
overStateNotifier
, but recognize when the simplicity ofStateNotifier
is valuable
With these guidelines in place, Riverpod becomes less of a "spider web" and more of a precisely crafted tool that simplifies your Flutter application's architecture rather than complicating it.
Remember that state management is inherently complex - Riverpod doesn't create this complexity but rather provides mechanisms to manage it effectively. By being intentional about how and when you use these mechanisms, you can build Flutter applications that are both powerful and maintainable.
If you have found this useful, make sure to like and follow for more content like this. To know when the new articles are coming out, follow me on Twitter and LinkedIn.
Subscribe to my newsletter
Read articles from Dinko Marinac directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Dinko Marinac
Dinko Marinac
Mobile app developer and consultant. CEO @ MOBILAPP Solutions. Passionate about the Dart & Flutter ecosystem.