State Management

Flutter Riverpod 3 Complete Migration Guide: From StateNotifier to Notifier in Production

Muhammad Shakil Muhammad Shakil
Feb 20, 2026
23 min read
Flutter Riverpod 3 Complete Migration Guide: From StateNotifier to Notifier in Production
Back to Blog

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 unificationProviderRef 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:

  1. Update dependencies first — bump flutter_riverpod to 3.x, add riverpod_generator and riverpod_annotation if going code-gen
  2. Fix all deprecation warnings — don't change behavior yet, just update deprecated API calls to their new equivalents
  3. Migrate StateNotifiers in batches — group by feature (auth providers, user providers, cart providers), migrate one group at a time
  4. Add riverpod_lint — catches migration mistakes automatically
  5. 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:

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.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.