Riverpod: Proven Patterns, Pitfalls, and Best Practices

Introduction
State management in Flutter has evolved a lot, but Riverpod continues to stand out — not because it’s the easiest to learn, but because it’s the most scalable and flexible if used correctly.
After three years of production-level work using Riverpod — from MVPs to enterprise apps — this article distills everything I wish I knew when I started. This isn’t just theory. These are insights hardened by 2 a.m. bugs, unreadable rebuild chains, and team discussions that helped tame Riverpod’s power.
Let’s make Riverpod your ally — not your enemy.
1: Foundation
Providers vs. Notifiers: Know the Role
A common mistake among Riverpod beginners (and even intermediate users) is misunderstanding what a Provider is for.
✅ Provider = Dependency Injection
✅ Notifier = State Management
Use a Provider to expose services, repositories, and utilities.
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient(baseUrl: "https://api.example.com");
});Use a Notifier to encapsulate app state and business logic.
final authNotifierProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
class AuthNotifier extends Notifier<AuthState> {
@override
AuthState build() {
return AuthState.unauthenticated();
}
Future<void> login(String email, String password) async {
final api = ref.read(apiClientProvider);
final response = await api.login(email, password);
state = AuthState.authenticated(user: response.user);
}
}Rule of Thumb: If it returns data once, use
Provider. If it returns and manages state over time, useNotifier.
ref.watch vs ref.read: Avoid the Pitfalls
- ๐ข
ref.watch: Triggers rebuilds. Use it inside widgets. - ๐ต
ref.read: Just gets the value once. Use it everywhere else (e.g. in services, callbacks,initState,AsyncNotifier, etc.).
Correct Usage:
// ✅ Inside a widget
final user = ref.watch(userProvider);
// ✅ In a service
final userRepo = ref.read(userRepositoryProvider);
await userRepo.login();Common Mistake:
Using watch inside Notifier logic, causing unexpected rebuild chains.
2: Debugging & Best Practices
How to Debug Riverpod Like a Pro
Riverpod can feel like a “black box” until you peek inside it.
1. Use onDispose
final serviceProvider = Provider((ref) {
ref.onDispose(() {
debugPrint("Service disposed");
});
return MyService();
});2. Use a Custom ProviderObserver
class AppObserver extends ProviderObserver {
@override
void didUpdateProvider(ProviderBase provider, Object? prev, Object? next, ProviderContainer container) {
debugPrint("Provider ${provider.name ?? provider.runtimeType} changed: $next");
}
}class AppObserver extends ProviderObserver {
@override
void didUpdateProvider(ProviderBase provider, Object? prev, Object? next, ProviderContainer container) {
debugPrint("Provider ${provider.name ?? provider.runtimeType} changed: $next");
}
}void main() {
runApp(
ProviderScope(
observers: [AppObserver()],
child: MyApp(),
),
);
}You’ll know exactly when providers get rebuilt, and why.
3: Real-World Application
Choosing Between Notifier, StateNotifier, and Async Variants
- Notifier → Preferred in Riverpod 2.x and beyond. Works best with
NotifierProvider. - StateNotifier → Still useful, especially for simple value-based state (like counters).
- AsyncNotifier → Great for async loading, caching, pagination, etc.
Example: Use AsyncNotifier for Loading + State Management
final todosProvider = AsyncNotifierProvider<TodosNotifier, List<Todo>>(TodosNotifier.new);
class TodosNotifier extends AsyncNotifier<List<Todo>> {
@override
Future<List<Todo>> build() async {
final repo = ref.read(todoRepositoryProvider);
return await repo.fetchTodos();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final repo = ref.read(todoRepositoryProvider);
return await repo.fetchTodos();
});
}
}Benefit: built-in loading/error handling with minimal boilerplate.
FutureProvider and StreamProvider: Still Useful?
Yes, but only for one-shot values or quick displays.
final weatherProvider = FutureProvider.autoDispose((ref) async {
final weatherRepo = ref.read(weatherRepositoryProvider);
return weatherRepo.getCurrentWeather();
});✅ Great for display widgets
❌ Not great for app-wide state — use AsyncNotifier instead
4: Modifiers Unpacked
.autoDispose: The Memory Saver
When a provider is no longer in use, .autoDispose helps free memory.
final tempStateProvider = NotifierProvider.autoDispose<TempNotifier, int>(TempNotifier.new);๐ก Use it when:
- State is temporary (search queries, form steps)
- Resources are heavy (e.g., geolocation, camera)
๐ด Avoid for:
- Persistent app-wide state (authentication, user session)
.family: Powerful but Dangerous
Allows parameterized providers.
final userProvider = FutureProvider.family<User, String>((ref, id) async {
return ref.read(userRepoProvider).fetchUserById(id);
});But your parameters must:
- Be immutable
- Override
==andhashCodecorrectly
Avoid passing raw collections like this:
// BAD: List will always be "new"
final listProvider = Provider.family((ref, List<int> list) { ... });✅ Do this instead:
@immutable
class Filter {
final int age;
final List<String> tags;
const Filter({required this.age, required this.tags});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Filter && age == other.age && tags.join() == other.tags.join();
@override
int get hashCode => age.hashCode ^ tags.join().hashCode;
}Use freezed, equatable, or AI tools to generate proper immutability.
5: The Myth of Annotations
@riverpod
UserRepository userRepository(Ref ref) {
return UserRepository(ref.read(apiProvider));
}The above saves almost no boilerplate vs manual Provider. Worse, you now depend on:
- Build runners
- Delayed codegen
- Limited IDE support
๐ With AI (GitHub Copilot, Cursor), it’s often faster to just write it yourself. Annotations add noise, not clarity.
6: Practical Patterns & Architecture
Clean Architecture with Riverpod
Layer your app for clarity:
lib/
├── features/
│ └── auth/
│ ├── domain/
│ ├── data/
│ ├── ui/
│ └── providers.dart
├── shared/
│ ├── widgets/
│ ├── services/
│ └── utils/
└── main.dart✅ Providers live close to their domain
✅ Logic in Notifiers, not widgets
✅ Services injected, not hardcoded
Testing Riverpod Logic Easily
Testing Riverpod providers is simple with ProviderContainer.
void main() {
test("auth notifier login test", () async {
final container = ProviderContainer();
final notifier = container.read(authNotifierProvider.notifier);
await notifier.login("email", "pass");
expect(container.read(authNotifierProvider), isA<Authenticated>());
});
}No need for pumpWidget. Test logic in isolation.
Conclusion
Riverpod is not inherently complex — it just demands discipline.
By understanding:
- The roles of different providers,
- When to use
watch,read,autoDispose, orfamily, - And how to structure your architecture cleanly,
…you unlock Riverpod’s true power: composability, scalability, and testability.
Happy coding, and may your rebuilds be intentional. ๐
Comments
Post a Comment