skip to content

Your Riverpod 2 codebase uses StateProvider and StateNotifierProvider; what does Riverpod 3 do with them, and how do you migrate them to Notifier?

level: seniorimportance: should knowfreq 35%

answer

  1. moved, not removed
  2. the legacy.dart import
  3. constructor logic moves to build
  4. recreated versus rebuilt
  5. == filtering changed in 3.0

basics

~10 s

Riverpod 3 keeps StateProvider and StateNotifierProvider but moves them to legacy.dart as discouraged. Migrate each to a Notifier: initial state and watched dependencies go into build(), methods assign state, and callers switch to NotifierProvider.

solid answer

~40 s

Upgrading to Riverpod 3 first means changing imports: `StateProvider`, `StateNotifierProvider` and `ChangeNotifierProvider` now come from `package:flutter_riverpod/legacy.dart`, which keeps old code compiling while marking it as not recommended. Migrating one of them to a `Notifier` is mechanical: the constructor's `super(initial)` becomes the value `build()` returns, dependencies the provider function passed into the constructor become `ref.watch` calls inside `build()`, and `StateProvider` writes such as `ref.read(p.notifier).state = x` become named methods. The behaviour change to check is lifecycle: a `StateNotifierProvider` disposed and recreated its notifier when a watched dependency changed, whereas a `Notifier` re-runs `build()` on the same instance. Also recheck notifications: Riverpod 3 filters every provider with `==`, so some listeners fire less or more often than in 2.x. `StateNotifier<AsyncValue<T>>` becomes an `AsyncNotifier<T>` without the hand-written `try`/`catch`.

code

dart · 26 lines
dart
// Before: from package:flutter_riverpod/legacy.dart
class NotificationFilterController extends StateNotifier<NotificationFilter> {
  NotificationFilterController(this._prefs) : super(_prefs.defaultFilter);

  final NotificationPrefs _prefs;

  void showMentionsOnly() => state = NotificationFilter.mentionsOnly;
}

final legacyFilterProvider =
    StateNotifierProvider<NotificationFilterController, NotificationFilter>(
  (ref) => NotificationFilterController(ref.watch(notificationPrefsProvider)),
);

// After: from package:flutter_riverpod/flutter_riverpod.dart
class NotificationFilterNotifier extends Notifier<NotificationFilter> {
  @override
  NotificationFilter build() => ref.watch(notificationPrefsProvider).defaultFilter;

  void showMentionsOnly() => state = NotificationFilter.mentionsOnly;
}

final filterProvider =
    NotifierProvider<NotificationFilterNotifier, NotificationFilter>(
  NotificationFilterNotifier.new,
);

go deeper

for a junior

Remember that StateProvider and StateNotifierProvider moved to legacy.dart in Riverpod 3 and that new code uses Notifier.

for a middle

Walk through the mechanical migration: initial state and dependencies into build(), state writes into named methods, NotifierProvider declaration.

for a senior

Identify behaviour changes - no recreation on dependency change, == filtering - and plan a staged migration with tests for each provider.

for a principal

Decide when a large codebase pays down legacy providers, balancing upgrade risk against the cost of two state styles coexisting.

## What Riverpod 3 changed **Riverpod 3.0** (stable since September 2025; 3.4 at the time of writing) cleaned up its provider catalogue. Three providers were marked as discouraged and moved to a separate import: - `StateProvider` - `StateNotifierProvider` (with `StateNotifier` and `StateController`) - `ChangeNotifierProvider` (flutter_riverpod only) They now come from `package:flutter_riverpod/legacy.dart` (or `package:riverpod/legacy.dart`). The documentation is explicit: they are **not removed**, only moved, so that code using them is visibly legacy. An upgrade can therefore be done in two steps - first fix the imports and ship, then migrate provider by provider. ## Migrating a StateProvider A `StateProvider` exposed a value any caller could overwrite through its `StateController`: ```dart // Riverpod 2 style, now from legacy.dart final filterProvider = StateProvider<NotificationFilter>((ref) => NotificationFilter.all); // anywhere: ref.read(filterProvider.notifier).state = NotificationFilter.mentionsOnly; ``` The `Notifier` equivalent names each allowed change: 1. Create `class NotificationFilterNotifier extends Notifier<NotificationFilter>`. 2. Return the initial value from `build()`. 3. Add methods such as `showMentionsOnly()` that assign `state`. 4. Declare `NotifierProvider<NotificationFilterNotifier, NotificationFilter>(NotificationFilterNotifier.new)`. 5. Replace every `.state =` write with a method call; `ref.watch(filterProvider)` reads stay the same. ## Migrating a StateNotifierProvider Riverpod's migration guide lists the structural differences: - a `StateNotifier`'s dependencies were declared in its **provider** function and passed to the constructor; a `Notifier` declares them with `ref.watch` inside **`build()`**; - a `StateNotifier`'s initialization was split between provider and constructor; a `Notifier` has **one place**, `build()`, and no logic in its constructor (using `ref` there throws); - a `StateNotifier` defined `dispose()` in the class; a `Notifier` registers cleanup with `ref.onDispose` inside `build()`. For asynchronous state, a `StateNotifier<AsyncValue<T>>` becomes an **`AsyncNotifier<T>`**: move the loading into `build()`, delete the `try`/`catch` that set `AsyncError` by hand, and drop `AsyncValue.guard` from initialization, since errors in `build()` already become `AsyncError`. ## Behaviour differences to test | Behaviour | StateNotifierProvider | Notifier (Riverpod 3) | |---|---|---| | A watched dependency changes | notifier disposed and recreated | `build()` re-runs on the same instance | | Where dependencies are read | provider function, passed to constructor | `ref.watch` in `build()` | | Cleanup | `dispose()` override | `ref.onDispose` in `build()` | | Notification filter | varied by provider type in 2.x | `==` for every provider since 3.0 | The last row is a Riverpod 3 change that affects even unmigrated code: in 2.x some providers compared with `identical` and some with `==`; in 3.0 all use `==`. Listeners that relied on an equal-but-new object to trigger a rebuild stop firing, and others fire more often. A notifier can override `updateShouldNotify` where the old behaviour is really needed. ## A safe order of work 1. Upgrade and switch imports to `legacy.dart`; run the tests. 2. Migrate `StateProvider`s, which are the simplest. 3. Migrate `StateNotifierProvider`s one at a time, checking code that relied on the notifier being recreated. 4. Remove the `legacy.dart` imports once nothing needs them.

  • Code relied on a StateNotifier's fields being reset whenever notificationPrefsProvider changed. What breaks after migrating to Notifier?
    A `Notifier` is not recreated when a dependency changes; only `build()` re-runs on the same instance. `state` is recomputed, but other fields keep their values. Reset such fields at the start of `build()`, or keep them in state instead of in fields.
  • Is it acceptable to ship a Riverpod 3 upgrade that still imports legacy.dart?
    Yes, as an intermediate step. The legacy providers are kept for backward compatibility, so switching imports gets the upgrade out safely; migrating to `Notifier` can then happen provider by provider, with tests, instead of in one risky change.

saying these in an interview costs you the question

  • Claims Riverpod 3 deleted StateProvider and StateNotifierProvider.
  • Keeps constructor initialization when converting a StateNotifier to a Notifier.
  • Assumes a Notifier is recreated when a watched dependency changes, like StateNotifier.
  • Keeps try/catch blocks that set AsyncError by hand inside an AsyncNotifier's build.
  • Ignores the switch to == filtering when listeners stop firing after the upgrade.