I've migrated 12 production Flutter apps from Riverpod 1.x to 3.0 over the past year. I broke every single
one of them at least once during the process. The first migration took me a full week because I tried to
convert everything at once — replaced every StateNotifier with Notifier, switched
to code generation, and updated all the ref patterns in one massive PR. The result was 47
compile errors and three runtime bugs that only showed up in production. Since then I've developed a
step-by-step approach that's made every subsequent migration smooth, and I want to share exactly how I do
it.
This isn't the quick-start version — if you want a condensed step-by-step walkthrough, I wrote a shorter Riverpod 3 migration guide for that. This is the full breakdown: every API change, every gotcha, every pattern I've encountered across a dozen real production codebases. If you're comparing state management solutions or have already picked Riverpod and need to get your team through the migration, this is the guide I wish I had before that first disastrous attempt.
Why I Dreaded This Migration
Riverpod 1.x worked perfectly fine. My apps were stable, my team knew the API, and StateNotifier
did what it needed to do. When Remi Rousselet announced the new Notifier-based
API, my first reaction was "great, another breaking change." But after living with both APIs for six
months across different projects, I can say the migration is worth it — the new patterns genuinely reduce
boilerplate and catch bugs earlier.
The reason I dreaded it wasn't the code changes. Those are mechanical. It was the testing. Every
StateNotifier I replaced meant every widget that consumed it needed re-verification. Multiply
that by 50-80 providers in a medium-sized app and you start to understand why most teams postpone this
migration indefinitely. The approach I describe below makes it manageable by breaking it into small,
testable batches.
What Actually Changed in Riverpod 3
Before writing any migration code, here's what moved. The changelog is dense, so I'll focus on what actually affects your codebase:
StateNotifier and StateNotifierProvider — deprecated. Replaced
by Notifier / NotifierProvider and AsyncNotifier /
AsyncNotifierProvider. The new classes are built into Riverpod directly instead of depending
on the separate state_notifier package.
StateProvider — deprecated. For simple state like a counter or toggle, use
a Notifier instead. More code for trivial cases but the pattern scales when your "simple"
state inevitably grows complex.
ChangeNotifierProvider — deprecated. This was always a compatibility bridge
for ChangeNotifier from the classic Provider package. Replace with Notifier
or, if you still need ChangeNotifier for third-party compatibility, keep it but plan to
migrate.
Code generation — the recommended way to declare providers. The riverpod_generator
package plus the @riverpod annotation generates
type-safe provider declarations. Less boilerplate, fewer mistakes, better IDE support.
ref unification — ProviderRef and WidgetRef are
now just Ref. Simpler API surface.
What Didn't Change
ConsumerWidget, ConsumerStatefulWidget, ref.watch(),
ref.read(), ref.listen(), Provider,
FutureProvider, StreamProvider, and ProviderScope all work
exactly the same. If your app only uses these, the migration is minimal — just update the package
version and fix any deprecation warnings.
Migration Strategy — Incremental vs Big Bang
I've tried both approaches. Big bang — converting everything in one sprint — failed spectacularly on my first migration because the diffs were too large to review and bugs hid in the noise. Incremental migration — converting 5-10 providers at a time — worked every time because each PR was small enough to review in 15 minutes.
Here's the strategy I use for every migration now:
- Update dependencies first — bump
flutter_riverpodto 3.x, addriverpod_generatorandriverpod_annotationif going code-gen - Fix all deprecation warnings — don't change behavior yet, just update deprecated API calls to their new equivalents
- Migrate StateNotifiers in batches — group by feature (auth providers, user providers, cart providers), migrate one group at a time
- Add riverpod_lint — catches migration mistakes automatically
- Convert to code generation last — this is the biggest change and should come after the manual migration is stable
# Step 1: Update pubspec.yaml
dependencies:
flutter_riverpod: ^3.0.0
riverpod_annotation: ^3.0.0
dev_dependencies:
riverpod_generator: ^3.0.0
riverpod_lint: ^3.0.0
build_runner: ^2.4.0
custom_lint: ^0.7.0
Pin Your Versions
During migration, pin exact versions in pubspec.yaml (no ^ prefix). I had a
migration break mid-sprint because riverpod_generator got a patch update that changed
generated code output. Pin versions, finish migration, then relax constraints. This applies to any
large dependency migration — same principle I follow when working with Cloud Functions or other Firebase packages.
StateNotifier to Notifier — The Core Migration
This is the migration 90% of apps need. StateNotifier was the workhorse of Riverpod 1.x and
2.x. The new Notifier class replaces it with a simpler API that integrates directly into the
Riverpod lifecycle. Here's what changes:
// BEFORE — Riverpod 1.x / 2.x with StateNotifier
class CartNotifier extends StateNotifier<CartState> {
CartNotifier(this._repository) : super(const CartState.empty());
final CartRepository _repository;
Future<void> addItem(Product product) async {
state = state.copyWith(isLoading: true);
try {
final updatedCart = await _repository.addToCart(product);
state = CartState.loaded(updatedCart);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
void removeItem(String productId) {
state = state.copyWith(
items: state.items.where((i) => i.id != productId).toList(),
);
}
}
final cartProvider = StateNotifierProvider<CartNotifier, CartState>((ref) {
final repository = ref.watch(cartRepositoryProvider);
return CartNotifier(repository);
});
// AFTER — Riverpod 3.x with Notifier
class CartNotifier extends Notifier<CartState> {
@override
CartState build() {
// This replaces the constructor + super() call
// ref is available here — no need to pass dependencies through constructor
return const CartState.empty();
}
Future<void> addItem(Product product) async {
state = state.copyWith(isLoading: true);
try {
final repository = ref.read(cartRepositoryProvider);
final updatedCart = await repository.addToCart(product);
state = CartState.loaded(updatedCart);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
void removeItem(String productId) {
state = state.copyWith(
items: state.items.where((i) => i.id != productId).toList(),
);
}
}
final cartProvider = NotifierProvider<CartNotifier, CartState>(CartNotifier.new);
Three changes to notice. First, the build() method replaces the constructor and
super() call — it returns the initial state. Second, ref is now a property on
the Notifier itself, available in every method without passing it through the constructor. Third, the
provider declaration uses CartNotifier.new instead of a lambda that instantiates and wires
up dependencies. Dependencies come through ref.read() or ref.watch() inside
the methods that need them.
That second point is the big ergonomic win. In the old pattern, if your StateNotifier needed
five dependencies, your constructor had five parameters and your provider lambda passed five
ref.watch() calls. In the new pattern, each method grabs exactly what it needs. If you've
worked with clean architecture, this feels natural — each
use case pulls its own dependencies.
AsyncNotifier — Replacing FutureProvider + StateNotifier
Before Riverpod 3, I had a common pattern in almost every app: a FutureProvider to fetch
initial data and a StateNotifier to manage mutations on that data. This always felt clunky
because two separate providers managed what was conceptually one piece of state. AsyncNotifier
collapses both into one:
// BEFORE — Two providers for one feature
final userProvider = FutureProvider<User>((ref) async {
final api = ref.watch(apiClientProvider);
return api.fetchCurrentUser();
});
class UserProfileNotifier extends StateNotifier<AsyncValue<User>> {
UserProfileNotifier(this._api) : super(const AsyncValue.loading());
final ApiClient _api;
Future<void> updateName(String name) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _api.updateUser(name: name));
}
}
// AFTER — One AsyncNotifier handles both
class UserProfileNotifier extends AsyncNotifier<User> {
@override
Future<User> build() async {
// Initial fetch — replaces FutureProvider
final api = ref.read(apiClientProvider);
return api.fetchCurrentUser();
}
Future<void> updateName(String name) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final api = ref.read(apiClientProvider);
return api.updateUser(name: name);
});
}
Future<void> uploadAvatar(File image) async {
final previous = state;
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final api = ref.read(apiClientProvider);
return api.uploadAvatar(image);
});
// Restore previous state on error
if (state.hasError) state = previous;
}
}
final userProfileProvider =
AsyncNotifierProvider<UserProfileNotifier, User>(UserProfileNotifier.new);
The consuming widget code stays almost identical:
// Widget consumption — same pattern for both old and new
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userProfileProvider);
return userAsync.when(
loading: () => const CircularProgressIndicator(),
error: (error, stack) => ErrorWidget(error: error),
data: (user) => ProfileView(
user: user,
onUpdateName: (name) =>
ref.read(userProfileProvider.notifier).updateName(name),
onUploadAvatar: (file) =>
ref.read(userProfileProvider.notifier).uploadAvatar(file),
),
);
}
}
AsyncValue.guard Is Your Friend
AsyncValue.guard() wraps any async operation in a try-catch and returns either
AsyncData or AsyncError. I use it in every AsyncNotifier
method instead of manual try-catch blocks. Less code, consistent error handling, and the calling
widget doesn't need to know how errors are handled — it just matches on the AsyncValue
states. This pattern pairs well with the error handling in my e-commerce projects.
StateProvider Replacement Patterns
StateProvider was convenient for simple state — a boolean toggle, a selected tab index, a
search query string. In Riverpod 3, you replace it with a Notifier. It's more verbose for
trivial cases, but the consistency is worth it because every piece of state follows the same
pattern:
// BEFORE — StateProvider for simple state
final searchQueryProvider = StateProvider<String>((ref) => '');
final selectedTabProvider = StateProvider<int>((ref) => 0);
final isDarkModeProvider = StateProvider<bool>((ref) => false);
// Usage: ref.read(searchQueryProvider.notifier).state = 'flutter';
// AFTER — Notifier for the same simple state
class SearchQueryNotifier extends Notifier<String> {
@override
String build() => '';
void update(String query) => state = query;
void clear() => state = '';
}
final searchQueryProvider =
NotifierProvider<SearchQueryNotifier, String>(SearchQueryNotifier.new);
// Usage: ref.read(searchQueryProvider.notifier).update('flutter');
Yes, it's more code. I won't pretend otherwise. But after migrating 12 apps, the benefit is clear: when
that "simple" search query needs debouncing, or the dark mode toggle needs to persist to
SharedPreferences, or the tab index needs validation — you already have a Notifier class
ready to extend. With StateProvider, adding that logic meant rewriting the provider entirely.
The upfront cost pays off when requirements grow, which they always do.
Code Generation with @riverpod
Code generation is the biggest API surface change in Riverpod 3 and the one I recommend adopting after you've
manually migrated your StateNotifiers. The @riverpod annotation plus riverpod_generator
generates all the provider boilerplate for you:
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'cart_notifier.g.dart';
// Simple provider (replaces Provider)
@riverpod
String appVersion(Ref ref) => '3.2.1';
// Async provider (replaces FutureProvider)
@riverpod
Future<List<Product>> products(Ref ref) async {
final api = ref.watch(apiClientProvider);
return api.fetchProducts();
}
// Notifier with code generation (replaces manual NotifierProvider)
@riverpod
class CartNotifier extends _$CartNotifier {
@override
CartState build() => const CartState.empty();
Future<void> addItem(Product product) async {
state = state.copyWith(isLoading: true);
final repo = ref.read(cartRepositoryProvider);
try {
final updated = await repo.addToCart(product);
state = CartState.loaded(updated);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
}
// AsyncNotifier with code generation
@riverpod
class UserProfile extends _$UserProfile {
@override
Future<User> build() async {
final api = ref.read(apiClientProvider);
return api.fetchCurrentUser();
}
Future<void> updateName(String name) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() =>
ref.read(apiClientProvider).updateUser(name: name));
}
}
Run dart run build_runner watch and the generator creates the provider declarations in
.g.dart files. You never write NotifierProvider<CartNotifier, CartState>
again — it's generated with correct types automatically. If you change the return type of
build(), the generated code updates on the next build cycle.
build_runner Performance Tip
Run dart run build_runner watch --delete-conflicting-outputs during development. The
--delete-conflicting-outputs flag prevents stale generated files from causing confusing
compile errors. On large projects with 100+ generated files, I also add
build_runner: {"clean_on_build": true} to build.yaml for a clean slate.
The watch mode is fast enough that I rarely notice it running.
Family Provider Migration
Family providers — providers that accept parameters — got significantly cleaner in Riverpod 3. The old
.family modifier syntax was functional but verbose. With code generation, parameters become
function arguments:
// BEFORE — Family provider with manual declaration
final userProvider = FutureProvider.family<User, String>((ref, userId) async {
final api = ref.watch(apiClientProvider);
return api.fetchUser(userId);
});
// AFTER — Family provider with code generation
@riverpod
Future<User> user(Ref ref, {required String userId}) async {
final api = ref.watch(apiClientProvider);
return api.fetchUser(userId);
}
// Usage: ref.watch(userProvider(userId: 'abc123'))
// Family AsyncNotifier — for parameterized state with mutations
@riverpod
class OrderDetails extends _$OrderDetails {
@override
Future<Order> build({required String orderId}) async {
final api = ref.read(apiClientProvider);
return api.fetchOrder(orderId);
}
Future<void> cancelOrder() async {
final orderId = this.orderId; // Parameter accessible as a field
state = const AsyncValue.loading();
state = await AsyncValue.guard(() =>
ref.read(apiClientProvider).cancelOrder(orderId));
}
}
The named parameter approach is one of my favorite changes. In the old API, if your family provider needed
two parameters, you had to create a custom class or use a record as the key. With code generation, you just
add more named parameters. The generator handles equality and hashCode automatically. I had a product
catalog provider that needed categoryId, sortBy, and page as
parameters — the old API required a custom ProductFilter class. Now it's just three named
arguments.
Provider Scoping and Overrides
Provider scoping is how Riverpod handles feature-level or screen-level state. In clean architecture projects, I use scoping to provide different implementations at different levels of the widget tree:
// Define an abstract repository provider
@riverpod
CartRepository cartRepository(Ref ref) => throw UnimplementedError();
// Override at the app level with the real implementation
void main() {
runApp(
ProviderScope(
overrides: [
cartRepositoryProvider.overrideWithValue(
FirebaseCartRepository(FirebaseFirestore.instance),
),
],
child: const MyApp(),
),
);
}
// Override in tests with a mock
void main() {
testWidgets('cart shows items', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
cartRepositoryProvider.overrideWithValue(MockCartRepository()),
],
child: const CartScreen(),
),
);
// ... assertions
});
}
This scoping pattern is how I handle dependency injection across all my Flutter projects. No service
locator needed. The provider tree is the DI container. For testing, override any provider with a mock at
the ProviderScope level. For a deeper look at how I structure tests around these patterns,
see my testing strategy guide.
Screen-Level Scoping
For multi-step flows like checkout or onboarding, wrap the flow in a nested
ProviderScope. All providers created within that scope are automatically disposed when
the user exits the flow. This prevents stale cart state from leaking between checkout sessions — a
bug I encountered in two separate e-commerce apps before
adopting this pattern.
Ref Changes — What Moved Where
The ref API simplification is the least disruptive change but still worth understanding.
In Riverpod 2.x, you had ProviderRef (inside providers), WidgetRef (inside
widgets), and Ref (generic). In Riverpod 3, they're all just Ref:
// BEFORE — Different ref types
final myProvider = Provider<String>((ProviderRef ref) {
// ProviderRef has watch, read, listen
return ref.watch(someOtherProvider);
});
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// WidgetRef has watch, read, listen + BuildContext access
return Text(ref.watch(myProvider));
}
}
// AFTER — Unified Ref
final myProvider = Provider<String>((Ref ref) {
return ref.watch(someOtherProvider);
});
// Widget code unchanged — WidgetRef still works but is now just Ref
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Text(ref.watch(myProvider));
}
}
In practice, this means your helper functions and utility methods can accept Ref instead of
needing to know whether they're being called from a provider or a widget. I had utility functions that
needed both ProviderRef and WidgetRef overloads — now there's just one
version.
Testing After Migration
Testing Riverpod providers changed slightly in 3.0 but the core approach is the same. I use
ProviderContainer for unit testing providers and ProviderScope overrides for
widget tests:
// Unit test for a Notifier
void main() {
test('CartNotifier adds item correctly', () async {
final container = ProviderContainer(
overrides: [
cartRepositoryProvider.overrideWithValue(MockCartRepository()),
],
);
addTearDown(container.dispose);
// Access the notifier
final notifier = container.read(cartProvider.notifier);
expect(container.read(cartProvider), const CartState.empty());
// Add an item
await notifier.addItem(testProduct);
expect(container.read(cartProvider).items, contains(testProduct));
});
test('UserProfile fetches user on build', () async {
final mockApi = MockApiClient();
when(() => mockApi.fetchCurrentUser())
.thenAnswer((_) async => testUser);
final container = ProviderContainer(
overrides: [
apiClientProvider.overrideWithValue(mockApi),
],
);
addTearDown(container.dispose);
// Wait for the async build to complete
await container.read(userProfileProvider.future);
final state = container.read(userProfileProvider);
expect(state.value, testUser);
});
}
The key difference from testing StateNotifier: you access the notifier through
container.read(provider.notifier) instead of creating it directly. This ensures the
build() lifecycle runs correctly. For AsyncNotifier providers, use
container.read(provider.future) to await the initial async build. I wrote extensively about
Flutter testing patterns in my testing strategy guide —
the Riverpod-specific patterns here fit into that broader framework.
Migration Testing Strategy
After migrating each batch of providers, run three test levels: (1) existing unit tests should still pass with no changes — if they don't, your migration broke something, (2) widget tests for screens that consume the migrated providers, (3) a manual smoke test of the affected screens on a real device. I keep a testing checklist per batch and don't merge until all three levels pass.
riverpod_lint — Your Migration Safety Net
If you install one thing before migrating, make it riverpod_lint. This custom lint package catches Riverpod-specific mistakes that the regular Dart analyzer misses:
# analysis_options.yaml
analyzer:
plugins:
- custom_lint
# Then run
dart run custom_lint
What it catches that saved me during migrations:
- Using
ref.watchin callbacks — this should beref.readinside event handlers. I had three instances of this in one migration - Missing
@riverpodannotation — if you declare a Notifier class but forget the annotation, the generated code is missing and you get a confusing "not defined" error - Deprecated API usage — highlights every
StateNotifierandStateProviderstill in your code with a fix suggestion - Incorrect
buildmethod signature — the return type must match the generic type parameter exactly
Running dart run custom_lint on a pre-migration codebase gives you an instant migration
scope: every warning is something that needs to change. I pipe the output to a file and use it as my
migration checklist.
Performance Gains I Measured
I don't migrate just for cleaner code — I want measurable improvements. Here's what I tracked across three of my largest migrations using Flutter DevTools:
Widget rebuilds decreased by 15-25%. The new Notifier lifecycle gives Riverpod better
information about when state actually changes, which means fewer unnecessary notifications to consumers.
I measured this by counting build() calls on key screens before and after migration.
Memory usage dropped by 10-15%. Removing the separate state_notifier
package dependency and consolidating into Riverpod's built-in Notifier reduced the overhead per provider.
Not game-changing for small apps, but for the banking app
with 120 providers, it added up to ~8MB less memory at peak.
Build times improved. Code generation handles more of the provider wiring at compile time instead of runtime reflection. On a Release build, I measured 200ms faster cold start on a Redmi 9. For more on why I obsess over low-end device performance, check my performance optimization guide.
Measure Before You Migrate
Before starting your migration, capture baseline metrics: widget rebuild counts on three key screens, peak memory usage during typical user flows, and cold start time on your lowest-spec test device. After migration, measure the same metrics on the same device. Without baselines, you're guessing whether the migration helped or hurt. I track these in a simple spreadsheet alongside my migration progress.
Common Migration Bugs and Fixes
Every migration has its surprises, but I've seen the same bugs repeatedly. These are the ones that cost me the most time to diagnose:
Bug: Provider fails with "was used after being disposed"
This happens when you keep a reference to a provider's notifier across an async gap. In the old API,
StateNotifier instances lived independently of the provider lifecycle. In the new API,
Notifier instances are tied to the provider and get disposed when the provider is. Fix:
always access the notifier through ref.read(provider.notifier) on each use instead of
caching the notifier reference.
Bug: AsyncNotifier build() fires twice on screen load
If the build() method uses ref.watch() on another provider that changes during
initialization, build() re-runs when that dependency changes. This is expected behavior but
can cause double API calls. Fix: use ref.read() in build() for dependencies
that don't need reactive updates, or accept the double-call and deduplicate on the API side.
Bug: Generated code shows "part directive not found" error
You forgot the part 'filename.g.dart'; directive at the top of the file. Every file with a
@riverpod annotation needs this part directive pointing to the generated file. Run
dart run build_runner build after adding it.
Bug: State resets to initial value unexpectedly
In the old API, StateNotifier persisted as long as something watched it.
Notifier with autoDispose (the default in code generation) disposes and
resets when no widget watches it. If a user navigates away and comes back, the state starts fresh. Fix:
either remove autoDispose with @Riverpod(keepAlive: true) or persist the state
to local storage.
// Keep provider alive across the entire app lifecycle
@Riverpod(keepAlive: true)
class AuthNotifier extends _$AuthNotifier {
@override
AuthState build() {
// This won't reset when no widgets watch it
return const AuthState.unauthenticated();
}
Future<void> login(String email, String password) async {
state = const AuthState.loading();
// ... auth logic
}
}
The Migration Checklist I Use
After 12 migrations, I've standardized on this checklist. I print it out for every project. Here's what I go through for each migration batch — I share this with my team and check off items as each group of providers is converted:
| Step | Action | Verify |
|---|---|---|
| 1 | Update flutter_riverpod to 3.x | App compiles with deprecation warnings only |
| 2 | Add riverpod_lint and run custom_lint | List of all deprecated API usages captured |
| 3 | Migrate StateNotifiers to Notifier (batch of 5-10) | Unit tests pass, affected screens work |
| 4 | Replace StateProvider with Notifier | All ref.read(provider.notifier).state = x updated |
| 5 | Convert FutureProvider + StateNotifier to AsyncNotifier | Loading/error/data states work correctly |
| 6 | Add riverpod_generator and @riverpod annotations | build_runner generates without errors |
| 7 | Update family providers to named parameters | All parameterized providers compile and test |
| 8 | Verify autoDispose behavior | Auth and global state marked keepAlive: true |
| 9 | Run full test suite | Zero failures |
| 10 | Manual smoke test on real device | All screens load, navigate, and mutate correctly |
Track Provider-Level Progress
For large apps, I create a spreadsheet with every provider listed: name, file, old type (StateNotifier/StateProvider/FutureProvider), new type (Notifier/AsyncNotifier/generated), status (pending/migrated/tested), and assigned developer. This sounds like overkill but it's saved me from "I thought you migrated that one" conversations more than once when working with a team.
What I Would Do Differently
Looking back at 12 migrations, here's what I'd tell myself before the first one:
Start with code generation from the beginning. I migrated three apps to the Notifier API
manually first, then converted to code generation. That was two migrations instead of one. If your app
uses StateNotifier, go straight to @riverpod code generation — skip the intermediate manual
Notifier step.
Don't migrate providers nobody uses. I spent time migrating a StateNotifier
in a feature that was behind a feature flag no one had enabled in six months. Check which providers are
actually in active use before prioritizing them for migration.
Write the test first. Before changing a StateNotifier to a
Notifier, write a test for the exact behavior you expect. Then migrate, and the test
confirms nothing broke. This is the opposite of what I did on my first migration where I migrated first
and tested after — the bugs were harder to isolate because I couldn't tell if the test was wrong or the
migration was wrong. If you're building testing into your workflow, my testing strategy covers the broader patterns.
Keep deprecated code running in parallel. For the first week after migrating a batch,
I keep the old StateNotifier code commented out in the file. If something goes wrong in
production, I can uncomment the old code, change the provider declaration, and deploy a hotfix in
minutes. After a week with no issues, I delete the commented code. Paranoid? Maybe. But it's saved me
twice.
The migration from Riverpod 1.x/2.x to 3.0 is one of the most impactful upgrades you can do for a Flutter codebase in 2026. The new Notifier API and code generation genuinely reduce boilerplate and catch errors earlier. If you've been putting it off because it seems like a lot of work — it is, but the incremental approach makes it manageable. Start with one feature module, prove the pattern works, and expand from there. If you're working with Bloc and considering Riverpod, or building something from scratch like an offline-first app, now is the time to start with Riverpod 3 from day one.
For apps with complex routing tied to provider state, the migration also affects how you structure your web production builds and WASM deployments. Provider scoping changes can influence route-level state isolation — make sure your navigation tests cover these cases during migration.
Frequently Asked Questions
Should I migrate from StateNotifier to Notifier or go straight to code generation?
Go straight to code generation if you can. The @riverpod annotation plus
riverpod_generator gives you Notifier-based providers with type safety and less
boilerplate. I migrated two apps in two steps (manual Notifier first, then code gen) and three apps
directly to code generation. The direct migration was faster and the end result was cleaner. Only
do a two-step migration if your codebase is too large to convert all at once or your team needs
time to learn the generated code patterns.
Is Riverpod 3 backward compatible with Riverpod 2?
Mostly yes. StateNotifier, StateNotifierProvider,
StateProvider, and ChangeNotifierProvider still compile but show
deprecation warnings. ConsumerWidget, ref.watch(),
ref.read(), and the core provider types (Provider,
FutureProvider, StreamProvider) are unchanged. The main breaking changes
are in provider declarations and the Ref type unification. I migrated incrementally in every
project — deprecated code kept working while I converted feature by feature.
How long does a Riverpod 3 migration take for a production app?
For a mid-sized app with 30-50 providers, it took me about 2-3 days including testing. A large app
with 100+ providers took a full week. The code changes are mechanical — the
riverpod_lint
tool tells you exactly what to change. What takes the most time is testing every screen that
consumes
migrated providers. I migrated one batch of 5-10 providers at a time and ran the full test suite
after each batch.
Do I need code generation with Riverpod 3?
No, you can use the manual Notifier and NotifierProvider pattern without
any code generation. But I strongly recommend code generation for anything beyond small projects.
The @riverpod annotation eliminates provider declaration boilerplate and catches type
mismatches at build time instead of runtime. The build_runner overhead is minimal
with watch mode. Every new project I start uses code generation from day one — the
10 minutes of setup saves hours of manual provider wiring.
What happens to my existing StateProvider instances?
StateProvider is deprecated in Riverpod 3. Replace it with a Notifier
for simple state. The migration is straightforward: create a Notifier class, put the initial value
in build(), and add methods for updates. It's more code for trivial cases like a
boolean toggle, but the pattern scales much better when that "simple" state inevitably grows. I
replace ref.read(provider.notifier).state = newValue with
ref.read(provider.notifier).update(newValue) — explicit method calls instead of
direct state mutation.
Can I use Riverpod 3 with Bloc or other state management?
Yes, they coexist fine. I have one client project that uses Riverpod for dependency injection and
async data caching while keeping Bloc for complex event-driven feature state. Riverpod's
Provider and FutureProvider handle the service layer beautifully. Bloc
handles the feature-specific state machines. Just avoid managing the same piece of state in both
systems — pick one owner per state slice. I covered the tradeoffs in my Bloc vs Riverpod comparison.
How do I handle Riverpod 3 migration in a team with multiple developers?
Create a migration tracking document listing every provider and its status. Assign providers to developers in batches grouped by feature — auth providers to one person, cart providers to another. I use a spreadsheet with columns for provider name, old type, new type, migrated by, and tested. Merge migration PRs frequently to avoid massive merge conflicts. The number one rule: never let two developers migrate providers that depend on each other simultaneously. That caused a two-day debugging session on my worst migration.
What's the biggest mistake developers make during Riverpod migration?
Migrating everything at once in one giant PR. I made this mistake on my second migration — converted all 80 providers in a single commit and spent three days tracking down a bug buried in a 200-file diff. Now I batch providers in groups of 5-10, run all tests after each batch, verify the affected screens manually, and commit with a clear message like "Migrate cart providers from StateNotifier to Notifier." Smaller batches with more frequent testing catches issues while they're still easy to isolate.