Riverpod 2.x & Provider — The Other State Management
Master Riverpod's compile-safe, testable state management — and know when to choose it over BLoC
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Riverpod is a reactive state management and dependency injection framework for Flutter that provides compile-safe, testable, and composable providers. Unlike the original Provider package, Riverpod providers are declared globally, don't depend on BuildContext, and support autoDispose, family parameterization, and code generation.
Real-world relevance
In a production Flutter app like a social media client, Riverpod manages: user auth state (AsyncNotifierProvider), feed posts with pagination (family + autoDispose), theme preferences (StateProvider), API client configuration (Provider), and WebSocket connections (StreamProvider). Each provider declares its dependencies explicitly, and testing requires only overrides in ProviderScope.
Key points
- Provider (package) vs Riverpod vs BLoC — the landscape — Provider (by Remi Rousselet) was Flutter's recommended state solution circa 2019 — it wraps InheritedWidget with a friendly API. Riverpod (also by Remi) is its successor: compile-safe, not tied to the widget tree, testable without BuildContext. BLoC (by Felix Angelov) enforces event→state streams. Key difference: Riverpod providers are global declarations read via Ref, BLoC blocs are instantiated in the widget tree. Riverpod catches missing providers at compile time; Provider throws at runtime.
- Core Riverpod provider types — Provider — read-only computed value, like a getter. StateProvider — simple mutable state (int, bool, enum). StateNotifierProvider — complex state with a StateNotifier class (Riverpod 1.x style, still supported). NotifierProvider — Riverpod 2.x replacement for StateNotifierProvider, uses synchronous Notifier class. AsyncNotifierProvider — for state that requires async initialization (API calls, database reads). StreamProvider — exposes a Stream as AsyncValue. FutureProvider — exposes a Future as AsyncValue.
- StateNotifierProvider vs NotifierProvider (migration path) — StateNotifierProvider uses a StateNotifier class where you mutate via `state = newState`. NotifierProvider (Riverpod 2.x) uses a Notifier class with a build() method that returns initial state. Key differences: Notifier has access to `ref` directly (no constructor injection), build() is called on initialization and on ref.invalidate(), and it works with code generation. Migration: replace `extends StateNotifier` with `extends _$YourNotifier` when using codegen, or `extends Notifier` without codegen.
- AsyncNotifierProvider — async state done right — AsyncNotifierProvider wraps state in AsyncValue, giving you .when(data:, loading:, error:) for free. The build() method returns Future — Riverpod handles loading/error states automatically. Use it for: fetching user profiles, loading settings from DB, paginated API lists. Unlike FutureProvider, AsyncNotifier lets you define mutation methods (add, update, delete) alongside the async state. The state is AsyncValue, so after a fetch you get AsyncData, AsyncLoading, or AsyncError — no manual loading booleans.
- Family modifier — parameterized providers — The .family modifier creates a provider that takes a parameter: `final userProvider = FutureProvider.family((ref, userId) => fetchUser(userId));`. Each unique parameter creates a separate provider instance with its own state and lifecycle. In the widget: `ref.watch(userProvider(42))`. With codegen: just add parameters to the annotated function. Gotcha: the parameter must implement == and hashCode correctly — use primitive types or freezed classes, not raw List/Map.
- AutoDispose modifier — preventing memory leaks — By default, Riverpod providers live forever once created. Adding .autoDispose disposes the provider when no widget is listening: `final dataProvider = FutureProvider.autoDispose((ref) => fetchData());`. This prevents memory leaks for screen-specific data. Use ref.keepAlive() inside the provider to temporarily prevent disposal (e.g., cache for 30 seconds). With codegen (@riverpod annotation), autoDispose is the DEFAULT — you opt out with @Riverpod(keepAlive: true).
- ref.watch vs ref.read vs ref.listen — the three reads — ref.watch(provider) — rebuilds the widget/provider when the value changes. Use in build() methods and provider bodies. ref.read(provider) — reads once, no subscription. Use in callbacks (onPressed, onTap) and event handlers. ref.listen(provider, callback) — runs a callback on change without rebuilding. Use for side effects: showing snackbars, navigation, logging. Common mistake: using ref.read in build() — the widget won't update when state changes. Common mistake: using ref.watch in onPressed — creates unnecessary rebuilds.
- ProviderScope & overrides for testing — ProviderScope is the root widget that stores all provider state. For testing, wrap your widget in ProviderScope with overrides: `ProviderScope(overrides: [userRepoProvider.overrideWithValue(MockUserRepo())], child: MyApp())`. This is Riverpod's killer feature for testing — no dependency injection setup, no service locators. You can override any provider at any level. Nested ProviderScopes create isolated state trees — useful for testing screens independently.
- Code generation with @riverpod — The riverpod_generator package lets you write providers as annotated functions or classes: `@riverpod Future user(UserRef ref, {required int id}) async => fetchUser(id);` generates a userProvider with autoDispose and family automatically. For stateful: `@riverpod class Counter extends _$Counter { @override int build() => 0; void increment() => state++; }` generates counterProvider. Benefits: less boilerplate, autoDispose by default, type-safe family parameters, consistent naming. Run: `dart run build_runner build`.
- select() — granular rebuilds — ref.watch(provider.select((state) => state.name)) only rebuilds when the selected value changes. Critical for performance with large state objects. Example: a UserState with 10 fields — if you only display the name, select ensures a change to email doesn't trigger a rebuild. Works with all provider types. Combine with == override or freezed for reliable equality checks.
- When to choose Riverpod over BLoC — Choose Riverpod when: you want compile-safe dependency injection, your team prefers functional reactive style, you need easy testing with overrides, you have many interdependent providers, or you're a solo/small team wanting less boilerplate. Choose BLoC when: your team is large and needs enforced architecture, you want explicit event traceability, you need bloc-to-bloc communication via streams, or your organization has existing BLoC expertise. Both are production-ready — the choice is team/project fit, not technical superiority.
- Common Riverpod mistakes in interviews — (1) Using ref.read in build() — no reactivity. (2) Forgetting autoDispose — providers accumulate state. (3) Using mutable objects as family parameters — broken equality. (4) Circular provider dependencies — Riverpod throws at runtime. (5) Putting business logic in widgets instead of Notifiers. (6) Not using select() on large state objects — unnecessary rebuilds. (7) Mixing Provider (package) and Riverpod in the same project — confusing, use one. Interviewers love asking about these pitfalls.
Code example
// Riverpod 2.x — NotifierProvider with AsyncNotifier example
// 1. Simple StateProvider
final counterProvider = StateProvider<int>((ref) => 0);
// 2. NotifierProvider (Riverpod 2.x style)
class TodoListNotifier extends Notifier<List<Todo>> {
@override
List<Todo> build() => []; // initial state
void addTodo(Todo todo) {
state = [...state, todo]; // immutable update
}
void toggleTodo(int id) {
state = [
for (final todo in state)
if (todo.id == id) todo.copyWith(done: !todo.done)
else todo,
];
}
void removeTodo(int id) {
state = state.where((t) => t.id != id).toList();
}
}
final todoListProvider =
NotifierProvider<TodoListNotifier, List<Todo>>(TodoListNotifier.new);
// 3. AsyncNotifierProvider — fetching from API
class UserProfileNotifier extends AsyncNotifier<UserProfile> {
@override
Future<UserProfile> build() async {
final repo = ref.watch(userRepoProvider);
return repo.fetchCurrentUser();
}
Future<void> updateName(String name) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final repo = ref.read(userRepoProvider);
return repo.updateName(name);
});
}
}
final userProfileProvider =
AsyncNotifierProvider<UserProfileNotifier, UserProfile>(
UserProfileNotifier.new,
);
// 4. Family + AutoDispose — parameterized provider
final userByIdProvider =
FutureProvider.autoDispose.family<User, int>((ref, userId) async {
final repo = ref.watch(userRepoProvider);
return repo.getUser(userId);
});
// 5. Widget usage
class UserScreen extends ConsumerWidget {
const UserScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// ref.watch — reactive, rebuilds on change
final profile = ref.watch(userProfileProvider);
final todos = ref.watch(todoListProvider);
// select — granular rebuild only when name changes
final userName = ref.watch(
userProfileProvider.select((p) => p.valueOrNull?.name ?? ''),
);
return profile.when(
data: (user) => Column(
children: [
Text('Hello, ${user.name}'),
ElevatedButton(
// ref.read — one-time read in callback
onPressed: () => ref.read(counterProvider.notifier).state++,
child: const Text('Increment'),
),
// ref.listen — side effect without rebuild
],
),
loading: () => const CircularProgressIndicator(),
error: (e, st) => Text('Error: $e'),
);
}
}
// 6. Testing with overrides
void main() {
testWidgets('shows user name', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userRepoProvider.overrideWithValue(MockUserRepo()),
userProfileProvider.overrideWith(() => MockUserProfileNotifier()),
],
child: const MaterialApp(home: UserScreen()),
),
);
await tester.pumpAndSettle();
expect(find.text('Hello, Test User'), findsOneWidget);
});
}Line-by-line walkthrough
- 1. counterProvider: simplest Riverpod provider — StateProvider holds a single int, ref.read(counterProvider.notifier).state++ to mutate
- 2. TodoListNotifier extends Notifier> — the Riverpod 2.x pattern replaces StateNotifier
- 3. build() returns initial state — called on creation and on invalidation (ref.invalidate)
- 4. addTodo creates a new list with spread operator — immutable state updates, never mutate in place
- 5. toggleTodo uses collection-for with conditional — idiomatic Dart for immutable list transformations
- 6. NotifierProvider declaration connects the Notifier class to its provider — TodoListNotifier.new is a tear-off constructor
- 7. UserProfileNotifier extends AsyncNotifier — build() returns Future, state is automatically AsyncValue
- 8. AsyncValue.guard wraps try/catch — state becomes AsyncError on failure, AsyncData on success
- 9. userByIdProvider: FutureProvider.autoDispose.family — parameterized by userId, auto-cleaned when widget disposes
- 10. ConsumerWidget gives you WidgetRef in build() — ref.watch for reactive reads, ref.read for callbacks
- 11. profile.when() destructures AsyncValue into data/loading/error — no manual loading boolean needed
- 12. Testing: ProviderScope overrides inject mocks — no DI container, no setup, just declare overrides in the array
Spot the bug
class CartNotifier extends Notifier<List<CartItem>> {
@override
List<CartItem> build() => [];
void addItem(CartItem item) {
state.add(item); // Add to cart
}
void removeItem(int id) {
state.removeWhere((item) => item.id == id);
}
double get total =>
state.fold(0, (sum, item) => sum + item.price * item.qty);
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Riverpod Official Documentation (riverpod.dev)
- Riverpod 2.x Migration Guide (riverpod.dev)
- Code Generation with Riverpod (riverpod.dev)
- Andrea Bizzotto — Riverpod Architecture Guide (codewithandrea.com)
- Riverpod vs BLoC — Comparison (codewithandrea.com)