Riverpod Deep Dive
5 min read
State Management
Riverpod is Provider's rewrite by the same author, designed specifically to fix Provider's pain points. Providers are global, compile-time safe, and BuildContext-free. Modern Riverpod (2.0+) uses code generation: declare a class with @riverpod and get a typed provider for free; use autoDispose for cleanup and family for parameterised providers.
| Provider pain point | Riverpod solution |
|---|---|
Needs BuildContext to access state | ref.watch(provider) works anywhere |
ProviderNotFoundException at runtime | Compile-time wiring |
| Two providers of the same type clash | Each provider is its own unique value |
| Tied to the widget tree | Providers live in a ProviderScope (which is just one InheritedWidget) |
| Testing requires widget pumping | Override providers in tests directly |
Mental model: providers are like top-level lazy globals — but scoped, overridable, and lifecycle-aware.
Code in action — the provider zoo
// 1️⃣ Provider — immutable values, services
final configProvider = Provider<AppConfig>((ref) => const AppConfig(apiUrl: '...'));
// 2️⃣ StateProvider — simple mutable state
final counterProvider = StateProvider<int>((ref) => 0);
// 3️⃣ NotifierProvider (modern, with codegen)
@riverpod
class Todos extends _$Todos {
@override
List<Todo> build() => [];
void add(Todo t) => state = [...state, t];
void remove(String id) => state = state.where((t) => t.id != id).toList();
}
// 4️⃣ FutureProvider — async loaded value
@riverpod
Future<User> user(UserRef ref) async {
final api = ref.watch(apiClientProvider);
return api.fetchUser();
}
// 5️⃣ StreamProvider — reactive stream
@riverpod
Stream<List<Message>> messages(MessagesRef ref) =>
ref.watch(socketProvider).messageStream;
// Consume in a widget
class Home extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userProvider);
return userAsync.when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
data: (u) => Text('Hello, ${u.name}'),
);
}
}
The two modifiers you'll use constantly
// autoDispose — release the provider when nobody is listening
@riverpod
Future<List<Result>> search(SearchRef ref, String query) async {
ref.watch(searchInputProvider); // re-runs on input change
return api.search(query);
} // (autoDispose is implicit with @riverpod)
// family — parameterised providers
@riverpod
Future<User> userById(UserByIdRef ref, String id) => api.fetchUser(id);
// Use
final user = ref.watch(userByIdProvider('user_123'));
autoDispose plus family = "one provider per route argument, cleaned up when the route is gone." That covers detail screens, paginated lists, search, etc.
ref.watch vs ref.read vs ref.listen
| API | Re-runs / rebuilds on change? | Use in |
|---|---|---|
ref.watch(p) | ✅ Yes | build, other providers (declares a dependency) |
ref.read(p) | ❌ No | Callbacks, event handlers |
ref.listen(p, fn) | ✅ Fires fn on change — does NOT rebuild | Side effects: snackbars, navigation, analytics |
Common mistakes to avoid
// ❌ ref.read inside build when you need reactivity
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.read(counterProvider); // doesn't update
return Text('$count');
}
// ✅ ref.watch
// ❌ Forgetting autoDispose on screen-scoped data → memory leak
final searchProvider = FutureProvider<List<Result>>((ref) async {...});
// ✅ FutureProvider.autoDispose, or @riverpod (autoDispose by default)
// ❌ Doing side effects in build() instead of ref.listen
final state = ref.watch(loginProvider);
if (state is Success) Navigator.pushReplacement(...); // 💥 navigates every rebuild
// ✅ ref.listen(loginProvider, (prev, next) {
// if (next is Success) Navigator.pushReplacement(...);
// });
// ❌ Mutating state in place instead of replacing it
state.add(item); // doesn't notify
// ✅ state = [...state, item];
// ❌ Forgetting family arguments must be value-equal
ref.watch(userByIdProvider(User(id: 'x'))); // new User instance every build → different provider!
// ✅ Pass a primitive ID: ref.watch(userByIdProvider('x'));
Interview follow-ups
-
How do you handle provider dependencies in Riverpod?
ref.watch(otherProvider)inside a provider body creates a dependency — whenotherProviderchanges, this provider rebuilds. Useref.readfor one-shot access without creating a dependency. Combined withref.invalidate(p)andref.refresh(p)you can rebuild parts of the graph on demand. -
Explain
ref.listenvsref.watch.ref.watchdeclares an ongoing dependency — rebuilds the consumer (widget or provider) on each change.ref.listen(provider, (prev, next) {...})fires a callback on change without rebuilding — use it for side effects like navigation, snackbars, analytics. Both can live inbuild. -
Why was Riverpod's code generation added in 2.0+? To kill three remaining boilerplate issues: declaring the right provider type by hand (
StateProvider,FutureProvider, etc.), maintaining thefamilyparameter signature, and matching arguments to typedRefs. With@riverpod, you write a function or class and the right provider is generated. -
How do you scope a provider for testing? Wrap your widget in
ProviderScope(overrides: [apiProvider.overrideWithValue(MockApi())]). For non-widget tests, create aProviderContainerdirectly:final c = ProviderContainer(overrides: [...]); c.read(myProvider);. No widget tree needed.
Follow-up Questions:
- "How do you handle provider dependencies in Riverpod?"
- "Explain ref.listen vs ref.watch"
How helpful was this content?
Please sign in to rate this article.