Sparround

Moving from Riverpod 2 to 3

Riverpod 3 keeps most existing code working but brings a set of important changes. The key points from the docs' "what's new" page:

Legacy APIs moved to a separate import. StateProvider, StateNotifierProvider and ChangeNotifierProvider were moved to package:riverpod/legacy.dart to discourage their use. They keep working for backward compatibility.

Breaking changes:

  • Refs and notifiers throw when accessed after disposal.
  • Exceptions from providers are wrapped in ProviderException for better debugging.
  • AsyncValue became sealed; valueOrNull was renamed to value.
  • All updateShouldNotify implementations now use == comparison.
  • AutoDisposeNotifier and FamilyNotifier were merged into a single Notifier class.
  • A unified Ref: provider-specific types such as FutureProviderRef were removed.

New capabilities: automatic retry (with exponential backoff), Ref.mounted, pause/resume (subscriptions paused automatically for widgets that are not visible), the experimental Mutation mechanism and experimental offline persistence (with implementations such as riverpod_sqflite).

ChangePractical impact
Legacy providers in a separate importThe code doesn't break but needs an added import — a signal to migrate gradually
`AsyncValue` became sealedPattern matching in a `switch` is checked exhaustively; `valueOrNull` must be renamed to `value`
Throwing on access after disposalPreviously silent mistakes now surface — new test failures may appear
Wrapping in `ProviderException`Code that checks error types must be revisited
Automatic retryThe UI's behaviour on network errors changes; an error screen may appear later
Merged notifier classesClasses written against `AutoDisposeNotifier`/`FamilyNotifier` must be updated
dart
// 1) valueOrNull → value
// v2:
final todos = ref.watch(todosProvider).valueOrNull;
// v3:
final todos = ref.watch(todosProvider).value;

// 2) Legacy provider-lər üçün import
// v3-də StateProvider/StateNotifierProvider/ChangeNotifierProvider:
import 'package:riverpod/legacy.dart';
// və ya yeni yanaşmaya keçid:
final counterProvider = NotifierProvider<Counter, int>(Counter.new);

// 3) Sealed AsyncValue → tam pattern matching
return switch (ref.watch(todosProvider)) {
  AsyncValue(:final value?) => TodoList(items: value),
  AsyncValue(:final error?) => ErrorView(message: '$error'),
  _ => const TodoSkeleton(),
};

// 4) Async-dan sonra provider-in hələ yaşadığını yoxlamaq
class Todos extends AsyncNotifier<List<Todo>> {
  @override
  Future<List<Todo>> build() => ref.watch(repositoryProvider).fetchAll();

  Future<void> refreshQuietly() async {
    final result = await ref.read(repositoryProvider).fetchAll();
    if (!ref.mounted) return; // v3: söküldükdən sonra müraciət xəta atır
    state = AsyncData(result);
  }
}

The four most common fixes during migration.

The surprising part of the migration is usually automatic retry: per the docs, when a provider throws during computation it is retried up to 10 times with an exponential backoff from 200 ms to 6.4 seconds. That can delay the appearance of an error screen when offline. The retry parameter (on a provider, ProviderScope or ProviderContainer) tunes the behaviour, and retry: (retryCount, error) => null disables it entirely. Worth knowing for tests with timing expectations.

📚 Sources and documentation