Riverpod: Proven Patterns, Pitfalls, and Best Practices

4 min readApr 21, 2025

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, use Notifier.

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, initStateAsyncNotifier, 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 NotifierStateNotifier, 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 == and hashCode correctly

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 freezedequatable, 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 watchreadautoDispose, or family,
  • 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. ๐Ÿš€

Muhammad Kashif

Comments