skip to content

Migrating a fitness app's hand-written Riverpod 3 providers to @riverpod, what behaviour changes and what breaks at the call sites?

level: seniorimportance: should knowfreq 30%

answer

  1. lifetime flips to auto-dispose
  2. names now derived, Notifier stripped
  3. record args become named parameters
  4. StateNotifier has no generated form
  5. XRef types gone in Riverpod 3

basics

~20 s

Generated providers auto-dispose by default, so state that relied on staying alive now resets; names are derived, so call sites and overrides change; families take real parameter lists; and legacy StateNotifier or ChangeNotifier providers must be rewritten, since codegen does not generate them.

solid answer

~40 s

First, lifetime: hand-written providers are kept alive by default, `@riverpod` ones auto-dispose, so run history refetches and a heart-rate stream is cancelled whenever no screen listens — mark app-wide ones `@Riverpod(keepAlive: true)`, and check that no keepAlive provider watches an auto-dispose one. Second, names: `class TimerNotifier` generates `timerProvider`, so every `ref.watch`, `ref.invalidate` and override is renamed. Third, families: a record argument like `sessionsProvider(('u1', day))` can become `sessionsProvider('u1', day: day)`. Fourth, legacy kinds: `StateNotifierProvider`, `StateProvider` and `ChangeNotifierProvider` sit in `legacy.dart` and are not generated, so a `StateNotifier` becomes a `@riverpod` class with `build()`, and a `GoRouter` is returned as `Raw<GoRouter>` with `ref.onDispose`. Finally, Riverpod 2-era generated code must replace `XRef` parameters with `Ref`. Migrate leaf providers file by file; both styles coexist.

code

dart · 20 lines
dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'timer.g.dart';

// Before (Riverpod 2 era, now in package:riverpod/legacy.dart):
// class TimerNotifier extends StateNotifier<int> {
//   TimerNotifier() : super(0);
//   void tick() => state++;
// }
// final timerProvider =
//     StateNotifierProvider<TimerNotifier, int>((ref) => TimerNotifier());

// After: generates timerProvider; kept alive like the original.
@Riverpod(keepAlive: true)
class TimerNotifier extends _$TimerNotifier {
  @override
  int build() => 0;

  void tick() => state++;
}

go deeper

for a junior

Know that generated providers auto-dispose by default and that the provider variable names now come from the generator.

for a middle

Explain how each hand-written kind maps to a function or class, how record arguments become named parameters, and why legacy StateNotifier providers need rewriting.

for a senior

Show a safe migration plan: audit lifetimes, keep keepAlive consistent, rename call sites and overrides, handle Raw values' disposal, and move file by file.

for a principal

Frame the migration's scope and sequencing across teams: whether the build-step cost is justified, how long both styles may coexist, and which conventions to enforce with lints.

## The starting point Picture a fitness app on Riverpod 3 whose providers are all hand-written: - `workoutRepositoryProvider`, a `Provider` wrapping the API client; - `runHistoryProvider`, a `FutureProvider` for past runs; - `heartRateProvider`, a `StreamProvider` over a sensor; - `sessionsProvider`, a `FutureProvider.family` taking a `(String, DateTime)` record; - `timerProvider`, a `StateNotifierProvider<TimerNotifier, int>` left over from Riverpod 2; - `routerProvider`, which returns a `GoRouter`. The team adopts riverpod_generator because freezed already runs a generator. Each group changes differently. ## 1. Lifetime flips to auto-dispose The largest change is silent. **Hand-written providers are kept alive by default; `@riverpod` providers auto-dispose by default.** After a naive migration: - run history refetches every time the history screen reopens; - the heart-rate stream is cancelled whenever no screen listens, then resubscribed; - the repository is recreated between screens if nothing keeps it listened to. Audit every provider that relied on staying alive and mark it `@Riverpod(keepAlive: true)` — the repository and the router certainly, run history if refetching is unwanted. Then check the reverse: a keepAlive provider that watches an auto-dispose one keeps that one alive forever, which riverpod_lint reports. ## 2. Names and call sites change The generator names the variable after the function or class: `class TimerNotifier` generates `timerProvider` (the default strip pattern removes the trailing `Notifier`), and a function `runHistory` generates `runHistoryProvider`. Every `ref.watch`, `ref.read`, `ref.listen`, `ref.invalidate` and every override that used the old variable must follow. If a hand-written variable with the generated name still exists in the same library, the two collide; delete the old one or pass `@Riverpod(name: ...)`. ## 3. Families get real parameter lists `sessionsProvider(('u1', day))` with a record argument can become `@riverpod Future<List<Session>> sessions(Ref ref, String userId, {required DateTime day})`, called as `sessionsProvider('u1', day: day)`. Arguments still need a consistent `==`. ## 4. Legacy kinds have no generated form | Hand-written today | After migration | |---|---| | `Provider` / `FutureProvider` / `StreamProvider` | `@riverpod` function | | `NotifierProvider` / `AsyncNotifierProvider` | `@riverpod` class extending `_$Name` | | `StateNotifierProvider` | rewrite as a `@riverpod` class; initial value moves from `super(0)` into `build()` | | `StateProvider` | `@riverpod` class with a setter-style method | | `ChangeNotifier` such as `GoRouter` | `Raw<GoRouter>` plus `ref.onDispose(router.dispose)` | `StateNotifierProvider`, `StateProvider` and `ChangeNotifierProvider` live in `package:riverpod/legacy.dart` in Riverpod 3, and riverpod_generator does not generate them; riverpod_lint's `unsupported_provider_value` warns if a `@riverpod` function returns a `StateNotifier`. With `Raw`, Riverpod neither listens to nor disposes the value, so disposal is yours. ## 5. Riverpod 2-era generated code Providers generated before Riverpod 3 took per-provider `Ref` types such as `RunHistoryRef`. Riverpod 3 removed every `Ref` subclass, so those parameters become plain `Ref`. riverpod_generator 4.0.0 also stopped emitting generated providers as constants, so a `const` expression that referenced one must drop `const`. ## 6. Tests and overrides Generated providers still offer `overrideWith` and `overrideWithValue`, but they carry the new names, and a family override targets the family's new call signature. How overrides are written belongs to the testing topic; the migration step is to rename and recompile every override list. ## A safe order 1. Add riverpod_annotation, riverpod_generator and riverpod_lint without migrating anything. 2. Migrate leaf providers (those nothing else depends on in complex ways) one file at a time; generated and hand-written providers can watch each other. 3. Decide `keepAlive` explicitly for each provider as you go, and note why in review. 4. Rewrite the legacy StateNotifier and ChangeNotifier providers last, since they change call sites the most. 5. Let the analyzer and riverpod_lint confirm nothing references removed names. ## Verifying the result A migration that compiles can still change behaviour, so check the parts the compiler cannot: - open the workout flow, leave it and come back: data that should persist (an active session, the timer) must survive, and data that should refresh must refetch; - watch the heart-rate sensor: the subscription should close when no screen shows it only if that is intended; - run the existing provider tests with their renamed overrides; a test that still passes after an override was silently dropped is a warning sign, so assert on the overridden value; - read the diff of every `.g.dart` file once for `isAutoDispose` values, since that single flag carries most of the behavioural change.

  • Why might a migrated provider keep its old variable name even though the generator derives names?
    Because the derived name can match the hand-written one. With the default strip pattern `Notifier$`, `class TimerNotifier` generates `timerProvider`, which is what many teams named the hand-written variable. When the old name was different, such as `timerNotifierProvider`, either rename the call sites or pass `@Riverpod(name: ...)` to keep it.
  • How do you migrate a router provider that returns a GoRouter, which is a ChangeNotifier?
    Return `Raw<GoRouter>` from a `@Riverpod(keepAlive: true)` function and register `ref.onDispose(router.dispose)`. `Raw` stops riverpod_lint's unsupported_provider_value warning and tells the generator not to treat the value specially; Riverpod will neither listen to the router nor dispose it, so the onDispose call is required.
  • What is the quickest way to spot providers whose lifetime changed after migration?
    List every provider that was hand-written without autoDispose and is now a plain @riverpod: each one changed from kept-alive to auto-dispose. Review them one by one for refetch cost, open subscriptions and user-entered state, and mark those that must survive with keepAlive: true.

saying these in an interview costs you the question

  • Migrating to @riverpod never changes when a provider's state is discarded.
  • Annotating an existing StateNotifierProvider variable with @riverpod migrates it.
  • Generated providers still take a RunHistoryRef parameter in Riverpod 3.
  • Everything must migrate at once because generated and hand-written providers cannot interact.
  • Returning Raw<GoRouter> means Riverpod disposes the router for you.