Start Debugging
2026-07-09 Updated 2026-07-09 migrationflutterdartriverpod Edit on GitHub

Migrate from Riverpod 2.x to Riverpod 3.0 in Flutter

A step-by-step upgrade from flutter_riverpod 2.x to 3.x: bump the packages, move StateProvider and friends to the legacy import, drop the AutoDispose and Family ref types, handle ProviderException wrapping and automatic retry, and fix the == notification filtering that silently drops StreamProvider events. Tested on Flutter 3.44, Dart 3.x, flutter_riverpod 3.3.2.

Upgrading a real app from flutter_riverpod 2.x to 3.x is usually a half-day job, and most of it is mechanical find-and-replace. The 3.0 line shipped in September 2025 and the current release is 3.3.2 (June 2026); this guide is tested on that version with Flutter 3.44 (stable, May 2026) and Dart 3.x. What actually breaks: StateProvider, StateNotifierProvider, and ChangeNotifierProvider move behind a legacy.dart import, every Ref subtype (FutureProviderRef, AutoDisposeNotifier, FamilyNotifier) collapses into one type, provider errors now come out wrapped in a ProviderException, and providers now filter notifications with ==. The last two are the ones that cause runtime surprises rather than compile errors, so read those sections carefully. If your codebase already uses code-generation (@riverpod), you have less to change than if you hand-wrote provider declarations.

Why upgrade at all

Riverpod 2.x still works, so the case for moving has to be concrete:

What breaks

AreaChangeSeverity
Legacy providersStateProvider, StateNotifierProvider, ChangeNotifierProvider need legacy.darthigh
Ref subtypesFutureProviderRef, StreamProviderRef, etc. all become a single Refhigh
AutoDispose / Family typesAutoDisposeNotifier, FamilyNotifier removed; use modifiers on Notifierhigh
Error propagationReads rethrow ProviderException wrapping your original errorhigh
Notification filteringAll providers use == to decide whether to notify listenersmedium
Automatic retryFailed providers retry by default with backoffmedium
ProviderObserverCallbacks take a single ProviderObserverContextmedium
AsyncValue.valueOrNullRenamed to value; the old throwing value getter is gonelow

Pre-flight checklist

Before you touch a single provider:

  1. Commit or stash everything. This migration touches a lot of files and you want a clean git diff to review.
  2. Confirm your Dart SDK is 3.x. Riverpod 3.0 requires it. Run dart --version and check.
  3. If you use code-generation, make sure build_runner runs cleanly on the current 2.x code first. You do not want to debug generator errors and migration errors at the same time.
  4. Note whether you use riverpod_lint. It ships lint rules and a dart fix migration helper that automates several of the steps below, so having it installed saves manual edits.

Step 1: Bump the packages

Update every Riverpod package in pubspec.yaml to the 3.x line in one go. Mixing a 2.x and a 3.x package will not resolve.

# pubspec.yaml -- flutter_riverpod 3.3.2, Dart 3.x
dependencies:
  flutter_riverpod: ^3.3.2
  riverpod_annotation: ^3.3.2   # only if you use code-generation

dev_dependencies:
  riverpod_generator: ^3.3.2    # only if you use code-generation
  riverpod_lint: ^3.3.2
  custom_lint: ^0.8.0
  build_runner: ^2.4.0

Then resolve:

# Flutter 3.44
flutter pub get

Verify: flutter pub deps | grep riverpod shows every Riverpod package on a 3.x version. If pub complains about a version conflict, a transitive dependency is still pinning riverpod 2.x; run flutter pub deps to find the culprit.

Step 2: Run the automated fixes first

Riverpod 3.0 ships dart fix migration rules through riverpod_lint. Run them before doing anything by hand, because they handle the tedious mechanical rewrites (the Ref subtype renames, the AutoDispose prefix removal) across every file at once.

# preview the changes without writing them
dart fix --dry-run

# apply them
dart fix --apply

Verify: re-run dart fix --dry-run and confirm the Riverpod-specific fixes are gone from the list. Then git diff and read what it changed. The tool is good but not omniscient, so the remaining steps are the parts it cannot infer.

Step 3: Move legacy providers behind the legacy import

StateProvider, StateNotifierProvider, and ChangeNotifierProvider still exist, but they now live in a separate library so the main import surface only exposes the modern API. If you import them from the main package, you get an “undefined name” error.

// Riverpod 3.x
// Add this import wherever you still use the legacy providers:
import 'package:flutter_riverpod/legacy.dart';

// StateProvider itself is unchanged in behaviour:
final counterProvider = StateProvider<int>((ref) => 0);

This is a deliberate nudge, not a deprecation warning you can ignore forever. The long-term move is to rewrite each StateProvider as a Notifier and each StateNotifierProvider as a Notifier or AsyncNotifier, the same target shape you would land on when migrating from the provider package. But you do not have to do that rewrite during the version bump. Add the import, get green, and convert later.

Verify: the app compiles. Grep for legacy.dart and confirm every file that uses a legacy provider has the import, and no file imports it without needing it (the linter will flag the unused import).

Step 4: Collapse the Ref subtypes and Notifier variants

In 2.x, a code-generated provider handed you a typed ref like CounterRef, and hand-written providers used FutureProviderRef<T>, StreamProviderRef<T>, and so on. In 3.0 there is a single Ref. The dart fix pass usually handles this, but hand-written declarations that the tool skipped need editing.

// Riverpod 2.x
int example(ExampleRef ref) => 0;

Future<User> user(UserRef ref) async => fetchUser();
// Riverpod 3.x -- one Ref type for everything
int example(Ref ref) => 0;

Future<User> user(Ref ref) async => fetchUser();

The same unification hits the class-based notifiers. AutoDisposeNotifier, FamilyNotifier, and the combinatorial explosion of names in between are gone. You express the same behaviour with modifiers on the base Notifier:

// Riverpod 2.x
class TodosNotifier extends AutoDisposeAsyncNotifier<List<Todo>> {
  @override
  Future<List<Todo>> build() => fetchTodos();
}
final todosProvider =
    AutoDisposeAsyncNotifierProvider<TodosNotifier, List<Todo>>(
  TodosNotifier.new,
);
// Riverpod 3.x -- autoDispose is a modifier, not a base class
class TodosNotifier extends AsyncNotifier<List<Todo>> {
  @override
  Future<List<Todo>> build() => fetchTodos();
}
final todosProvider =
    AsyncNotifierProvider.autoDispose<TodosNotifier, List<Todo>>(
  TodosNotifier.new,
);

Family parameters that used to live in FamilyNotifier now arrive as regular build arguments (code-gen) or through the .family modifier (manual). If you use @riverpod, the generator handles the wiring and you only rerun it.

Verify (code-gen users): delete the generated files and rebuild.

dart run build_runner build --delete-conflicting-outputs

Confirm zero generator errors and that the regenerated .g.dart files reference Ref, not the old typed refs.

Step 5: Handle ProviderException wrapping

This is the change most likely to slip past a compile check and break at runtime. In 3.0, when a provider’s build throws and another piece of code reads it imperatively, the read does not rethrow your original exception. It rethrows a ProviderException that wraps it. Any on MyException catch block that was catching the original type stops firing.

// Riverpod 2.x -- this used to work
try {
  final user = await ref.read(userProvider.future);
} on NotFoundException catch (e) {
  showNotFound();
}
// Riverpod 3.x -- catch the wrapper, inspect .exception
try {
  final user = await ref.read(userProvider.future);
} on ProviderException catch (e) {
  if (e.exception is NotFoundException) {
    showNotFound();
  } else {
    rethrow;
  }
}

The unwrapped path is AsyncValue. When you render a provider with .when(error: ...) or pattern-match an AsyncError, the error you get there is your original exception, not the wrapper. So UI code that reads state reactively is unaffected; only imperative ref.read(...future) inside a try/catch needs the change. The dedicated write-up on Riverpod 3.0’s ProviderException covers the corner cases.

Verify: grep for ref.read( combined with .future inside try blocks, and for any catch clause that names a domain exception. Add a test that makes a provider throw and asserts your handler still runs.

Step 6: Fix the == notification filtering

In 2.x, different provider types had different rules for deciding when to notify listeners. In 3.0 they all use ==. For Notifier state this is usually fine, but it bites hard with StreamProvider and StreamNotifier: if your stream emits objects that are equal by == (for example, mutable classes that do not override equality, or two values that happen to compare equal), the second emission is now dropped as a duplicate.

The failure mode is a UI that stops updating even though the stream is clearly emitting. The fix is to make sure the emitted type has correct equality semantics. If you emit domain objects, give them value equality (a record, a Freezed class, or a hand-written ==/hashCode). See Dart records vs Freezed classes for which to reach for.

// Riverpod 3.x -- two distinct ticks that are == would be collapsed.
// A record gives structural equality so each tick is treated as new.
Stream<({int count, DateTime at})> ticks(Ref ref) async* {
  var n = 0;
  await for (final _ in Stream.periodic(const Duration(seconds: 1))) {
    yield (count: n++, at: DateTime.now());
  }
}

If you genuinely need every emission through regardless of equality, override updateShouldNotify on a StreamNotifier to return true.

Verify: run the app, watch any stream-backed widget, and confirm it still updates on every emission you expect. This one has no compile signal, so it needs a manual smoke test.

Step 7: Decide on automatic retry

Failed providers now retry automatically: a 200 ms initial delay that doubles up to 6.4 seconds. For most network-backed providers this is an upgrade. But if you have a provider whose failure is permanent (a validation error, a 404 that will never become a 200), the silent retries waste calls and can mask the error in the UI for several seconds.

Opt out globally at the scope, or per provider:

// Riverpod 3.x -- disable retry everywhere
ProviderScope(
  retry: (retryCount, error) => null, // null delay = do not retry
  child: MyApp(),
)
// Or keep it, but stop retrying non-transient errors
ProviderScope(
  retry: (retryCount, error) {
    if (error is NotFoundException) return null;
    if (retryCount >= 3) return null;
    return Duration(milliseconds: 200 * (1 << retryCount));
  },
  child: MyApp(),
)

Verify: point a provider at an endpoint that returns a 404 and confirm it does not hammer the server, or that your retry predicate short-circuits as intended.

Step 8: Update ProviderObserver and the valueOrNull rename

If you have a custom ProviderObserver (analytics, logging), its callbacks changed signature. The container and provider arguments merged into a single ProviderObserverContext.

// Riverpod 3.x
class LoggingObserver extends ProviderObserver {
  @override
  void didUpdateProvider(
    ProviderObserverContext context,
    Object? previousValue,
    Object? newValue,
  ) {
    debugPrint('${context.provider.name} -> $newValue');
  }
}

And the small one: AsyncValue.valueOrNull is renamed to value. The old value getter (which threw on loading/error) is gone. If you relied on the throwing behaviour, pattern-match the AsyncData case instead.

Verify: the analyzer flags every valueOrNull call site; the dart fix pass from Step 2 usually rewrites them, but confirm none remain.

Verification: the full smoke test

After all steps, run the checklist end to end:

Rollback plan

This migration is a one-way door in practice. The moment you write import 'package:flutter_riverpod/legacy.dart' and adopt the single Ref type, reverting means undoing every file. The clean rollback is git, not code: do the whole migration on a branch, keep the 2.x branch untouched, and only merge once the smoke test passes. Do not do a half-migration and ship it; a codebase with some files on 3.x semantics and some on 2.x assumptions (especially around error wrapping) is worse than either version alone.

Gotchas we hit

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back