· 6 min read · Nadeera Silva
Riverpod 3 Best Practices for Production Flutter Apps (2026)
Riverpod 3 best practices for production Flutter apps: migrating from 2.x and StateProvider, structure, the generator, testing, and how it compares with Bloc.

Riverpod is still the state management library I reach for first in new Flutter apps. Riverpod 3.0 shipped in September 2025, and the current release is flutter_riverpod 3.4.3. A lot of what's online is written for 2.x, so this post covers the Riverpod 3 best practices I use in production: how to structure providers, how to migrate from StateProvider and StateNotifier, whether you need the generator, how to test, and how Riverpod compares with Bloc.
What's new in Riverpod 3
The Riverpod team warns that 3.0 "contains a few life-cycle changes" that "could break your app in subtle ways". These are the ones to know before upgrading:
| Change in 3.0 | What it means for you |
|---|---|
| Automatic retry | A provider that throws during initialization retries with exponential backoff. Configure or disable it with retry on ProviderScope or per provider. |
| Legacy providers moved | StateProvider, StateNotifierProvider and ChangeNotifierProvider now live in legacy.dart. They work, but new code should use Notifier and AsyncNotifier. |
One Ref type | AutoDisposeNotifier, FamilyNotifier and the typed Ref variants are gone. |
Ref.mounted | Check it after an await before touching state, like BuildContext.mounted. |
Updates filtered with == | All providers use == to decide whether to notify listeners. |
| Out-of-view listeners pause | Widgets that aren't visible stop receiving updates until they're shown again. |
ProviderContainer.test | A test container that disposes itself when the test ends. |
Offline persistence and mutations also arrived in 3.0, but the docs mark both as experimental. I wouldn't build a production feature on either yet.
Riverpod 3 migration from 2.x, step by step
- Bump
flutter_riverpodto 3.x (andriverpod_annotation,riverpod_generatorandriverpod_lintif you use them). - Do a case-sensitive replace of
AutoDisposewith nothing, as the migration guide suggests:AutoDisposeNotifierbecomesNotifier. - Change legacy imports to
package:flutter_riverpod/legacy.dartso the app compiles, then migrate those providers one feature at a time. - Move family arguments from
buildinto a constructor (example below). - Add
if (!ref.mounted) return;after awaits in notifier methods. - Decide on retry: keep the default, or turn it off where you show your own error UI.
Replacing StateProvider with a Notifier
// Before: StateProvider, now in legacy.dart
final counterProvider = StateProvider<int>((ref) => 0);
ref.read(counterProvider.notifier).state++;
// After: a Notifier with named methods
final counterProvider = NotifierProvider<Counter, int>(Counter.new);
class Counter extends Notifier<int> {
@override
int build() => 0;
void increment() => state++;
}
ref.read(counterProvider.notifier).increment();It's a few more lines, but every change now goes through a method you can search for, log and test.
Family notifiers take a constructor argument
final todoProvider =
AsyncNotifierProvider.autoDispose.family<TodoNotifier, Todo, String>(TodoNotifier.new);
class TodoNotifier extends AsyncNotifier<Todo> {
TodoNotifier(this.id);
final String id;
@override
Future<Todo> build() => ref.watch(todoRepositoryProvider).fetch(id);
}A family caches one provider per argument using == and hashCode. Pass IDs or records, not a new list or an object without value equality, or you'll get a fresh provider on every rebuild.
How I structure providers in a production app

Three layers, each in its own feature folder:
- A repository provider that wraps the API or database. No UI state.
- An AsyncNotifier per feature that loads in
buildand exposes methods for changes. - Widgets that
watchthe notifier and call its methods from event handlers.
import 'package:flutter_riverpod/flutter_riverpod.dart';
final todoRepositoryProvider = Provider<TodoRepository>((ref) {
return HttpTodoRepository(ref.watch(httpClientProvider));
});
final todosProvider =
AsyncNotifierProvider<TodosNotifier, List<Todo>>(TodosNotifier.new);
class TodosNotifier extends AsyncNotifier<List<Todo>> {
@override
Future<List<Todo>> build() => ref.watch(todoRepositoryProvider).fetchAll();
Future<void> add(String title) async {
await ref.read(todoRepositoryProvider).add(title);
if (!ref.mounted) return; // the provider may have been disposed meanwhile
ref.invalidateSelf();
await future;
}
}build only reads. Side effects are methods the UI calls on purpose, so a rebuild can't send a request twice.
In widgets, watch in build and read in callbacks. AsyncValue is sealed, so a switch forces you to handle every state:
class TodoList extends ConsumerWidget {
const TodoList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return switch (ref.watch(todosProvider)) {
AsyncData(:final value) => ListView(
children: [for (final t in value) Text(t.title)],
),
AsyncError(:final error) => Center(child: Text('Could not load: $error')),
_ => const Center(child: CircularProgressIndicator()),
};
}
}Do you need riverpod_generator?
No. Riverpod's docs say code generation "is completely optional", and every example above works without build_runner. I use it on bigger projects because families and auto-dispose become plain function parameters:
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'todo.g.dart';
@riverpod
Future<Todo> todo(Ref ref, String id) =>
ref.watch(todoRepositoryProvider).fetch(id);
// generates todoProvider, used as ref.watch(todoProvider('42'))Run dart run build_runner watch while you work. Generated providers are auto-dispose by default; add @Riverpod(keepAlive: true) where you want them cached.
Testing Riverpod providers
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
void main() {
test('loads todos from the repository', () async {
final container = ProviderContainer.test(
overrides: [
todoRepositoryProvider.overrideWithValue(FakeTodoRepository()),
],
);
final todos = await container.read(todosProvider.future);
expect(todos, hasLength(2));
});
}Awaiting .future waits for the async state to settle, with no timers or pump calls. For widget tests, wrap the widget in a ProviderScope with the same overrides.
Riverpod vs Bloc
I've shipped both. This is how I'd compare them for a team choosing today:
| Riverpod 3 | Bloc (flutter_bloc 9) | |
|---|---|---|
| Unit of state | Providers and notifiers | Blocs (events in, states out) and Cubits |
| Dependency injection | Built in: providers read other providers | Through BlocProvider and RepositoryProvider in the widget tree |
| Boilerplate | Low, lower with the generator | Higher for Blocs, similar for Cubits |
| Async loading and errors | AsyncValue built in | You model loading and error states yourself |
| Works outside widgets | Yes, ProviderContainer | Yes, Blocs are plain Dart |
Bloc's event-driven style suits teams that want every change as a named event with a strict log. For most apps I build, Riverpod gets to the same structure with less code. If an app already uses Bloc well, I don't migrate it.
Riverpod best practices checklist
- New code uses
NotifierandAsyncNotifier, not legacy providers. buildreads, methods write.ref.mountedafter everyawaitin a notifier.- Family arguments are IDs or records with value equality.
autoDisposeon detail screens opened many times.riverpod_lintenabled. Since 3.x it runs as an analysis server plugin: addplugins: riverpod_lint: <version>toanalysis_options.yaml, anddart analyzereports it in CI.- The Riverpod DevTools extension (updated in 3.4.1) for inspecting providers.
Inheriting an app with tangled state? My playbook for taking over a live Flutter app covers stabilising it first. Upgrading the iOS side too? See the Flutter UIScene migration guide.
Frequently asked questions
What's new in Riverpod 3?
Automatic retry for failing providers, legacy providers moved to legacy.dart, one Ref type, Ref.mounted, == filtering for all providers, paused out-of-view listeners and ProviderContainer.test.
How do I migrate from Riverpod 2 to 3?
Remove AutoDispose from notifier class names, change legacy imports to legacy.dart, move family arguments into the notifier constructor, add ref.mounted checks after awaits, and decide how you want retry to behave.
Are StateNotifierProvider and StateProvider removed in Riverpod 3?
No. They moved to the legacy import, for example package:flutter_riverpod/legacy.dart. New code should use Notifier and AsyncNotifier.
Is riverpod_generator required?
No. Riverpod's docs say code generation is completely optional. It reduces boilerplate for families and auto-dispose providers.
Riverpod vs Bloc: which should I use?
Both are solid. Bloc suits teams that want every change as a named event. Riverpod reaches a similar structure with less code and has async loading and error states built in. Don't migrate an app that already uses Bloc well.
Why does my failing provider keep retrying?
Riverpod 3 retries providers that fail during initialization, with exponential backoff. Pass a retry function that returns null to turn it off.
How do I test an AsyncNotifier?
Create a container with ProviderContainer.test, override the repository with a fake, and await container.read(provider.future).
Sources
- What's new in Riverpod 3.0 (Riverpod)
- Migrating from 2.0 to 3.0 (Riverpod)
- About code generation (Riverpod)
- Testing your providers (Riverpod)
- flutter_riverpod (pub.dev)
- riverpod changelog (pub.dev)
- riverpod_generator (pub.dev)
- riverpod_lint (pub.dev)
- flutter_bloc (pub.dev)
Keep reading
- How to Hide API Keys in Flutter (and What to Do Instead)You can't hide an API key inside a Flutter app. Which keys are safe to ship, how to restrict them, and how to keep real secrets behind a proxy with App Check.
- Taking Over an Existing Flutter App and PHP Backend: My PlaybookHow I take over a live Flutter app with a legacy PHP backend: a first-week checklist, reproducing bugs, PHP security fixes and a safe move to token auth.
- Flutter UIScene Migration: Fix iOS 27 Launch CrashesFlutter apps built with Xcode 27 quit at launch without the UIScene lifecycle. Here's how to migrate a custom AppDelegate, Info.plist and plugins.
Comments
Share a question or note about this article.
Loading comments…