🏞️

【Flutter】Riverpod 3 への移行ガイド

に公開

はじめに

2025年9月10日にRiverpod3.0が正式にリリースされました。

https://riverpod.dev/docs/whats_new

今回のアップデートで便利になった反面、2.x と同じ感覚でコードを書くと、
思わぬ落とし穴にハマるケースもありました。
2.x系を使っていた自分が3.x系に移行するにあたっての注意事項を記事にまとめました。

これからRiverpodの3.x系へバージョンアップを行う方の参考になれば幸いです。

記事の対象者

  • Riverpod 2.x系から3.x系へ移行を行う Flutter/Dart 開発者
  • AsyncValue / Notifier / Providerライフサイクル変更点を押さえたい人
  • Stream/非同期Providerの新仕様と落とし穴を避けたい人
  • テストで retry/override 周りの新挙動に対応したい人

記事を執筆時点での筆者の環境

[✓] Flutter (Channel stable, 3.35.4, on macOS 26.0.1 25A362 darwin-arm64, locale ja-JP)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.104.3)
[✓] Connected device (5 available)
[✓] Network resources
dependencies:
  flutter:
    sdk: flutter
  hooks_riverpod: 3.0.3
  flutter_hooks: ^0.21.0
  riverpod_annotation: 3.0.3

dev_dependencies:
  flutter_test:
    sdk: flutter
  riverpod_generator: 3.0.3
  build_runner: ^2.4.8
  custom_lint: ^0.8.0
  riverpod_lint: 3.0.3

今回の記事で触れない機能

様々な機能が今回のアップデートでは搭載されました。
その中で大きなところで言うと以下の二つの機能については今回触れておりません。

  1. オフライン永続化
  2. ミューテーション

個々の機能についての解説は割愛致しますが、こちらは完全に新機能であると言うことに加えて、まだまだ実験的だと公式で言及されています。
大変便利な内容ではあるのですが、プロダクトに反映するのは慎重に検証&検討した方が良いと思います。

実装のマイグレーション

valueOrNullとvalueの統廃合

AsyncValueに対して switchwhen で書かずに、値があれば返す、ローディング中かエラーの場合はnullを返す と言うゲッターとして valueOrNull がありました。

別のゲッターとして value と言うものもありましたが、こちらは valueOrNull と基本一緒なのですが、唯一の違いとして、1回目の取得時にエラーであれば exception を返すと言うものでした。

しかし、今回それが内容は valueOrNull だけど命名は value を使うようになりました。
つまり今までの value は廃止となりました。
ややこしいのですが、まとめると以下の内容となります。

状態 戻り値
初回ローディング中 null
初回でエラー null
2回目以降でローディング中 直前の値
2回目以降でエラー 直前の値
データ取得成功 新しい値

実際にはvalueOrNullと記述しているところを検索置換などで置き換えていきましょう。

// Riverpod 2.x系での書き方
final asyncUserName = ref.watch(userNameProvider);
final user = asyncUserName.valueOrNull ?? 'Unknown'; // null になる可能性あり


// Riverpod 3.x系での書き方
final asyncUserName = ref.watch(userNameProvider);
final user = asyncUserName.value ?? 'Unknown'; // null になる可能性あり

私は今まで .value を使ってこなかったので置き換えだけでよかったのですが、意図的に .value を使っていて、初回のエラーを考慮して書いている場合は挙動が変わるので注意が必要です。

Refのサブクラス廃止

こちらは厳密にはRiverpod2.6.0から入った変更ですが、公式ドキュメントの3.0の変更内容にも記載があるので一応載せておきます。

いわゆるproviderを宣言する時に書いていたRef名がサブクラス xxxRef ではなく Ref でよくなったよ!と言うものです。
こちらはアップデートすると大量に警告が出るのですが、地道に置き換えていきましょう。

// ===== 変更前 (Riverpod 2.x系) =====

Future<User> fetchUser(FetchUserRef ref) async {
  // ...
}

// ===== 変更後 (Riverpod 3.x系) =====

Future<User> fetchUser(Ref ref) async {
  // ...
}

retryの設定

これまで、Providerの生成に失敗した場合はすぐさまエラーとして処理されていました。
3.0以降ではエラーが発生した場合はデフォルトで再試行を行うようになりました。
再試行の間隔は200ミリ秒の遅延から始まり、再試行ごとに遅延が倍増して最大6.4秒になります。試行回数は10回に設定されています。

こちらの動作をカスタマイズしたい場合はmain関数直下の ProviderScope で設定できます。

void main() {
  runApp(
    ProviderScope(
      // You can customize the retry logic, such as to skip
      // specific errors or add a limit to the number of retries
      // or change the delay
      retry: (retryCount, error) {
        if (error is SomeSpecificError) return null;
        if (retryCount > 5) return null;

        return Duration(seconds: retryCount * 2);
      },
      child: MyApp(),
    ),
  );
}

上記は全てのProviderに設定されますが、providerごとに個別の設定も設定できます。

Duration retry(int retryCount, Object error) {
  if (error is SomeSpecificError) return null;
  if (retryCount > 5) return null;

  return Duration(seconds: retryCount * 2);
}

(retry: retry)
class TodoList extends _$TodoList {
  
  List<Todo> build() => [];
}

この機能が有効活用される想定は例えばProviderの中でAPI通信が失敗した場合の再試行などと思われます。
しかし、私の場合はこの設定は不要だったのでディセーブルにすべく以下のようにしました。

  runApp(
    ProviderScope(
      // リトライを無効化
      retry: (_, __) => null,
    // ....
    ),
  );

なお、このretryの仕組みは テストでも適用されます。
テストにおいてはテストファイルごとに ProviderScope を定義するので注意が必要です。

詳しくは別途後述します。

Providerのライフサイクル変更関連

今回の新機能以外での大きな変更点はこの Providerのライフサイクル変更関連 です。

https://riverpod.dev/docs/whats_new#pauseresume-support

大まかな概要としては

  • Providerが表示されなくなると一時停止される (制御は可能)
  • Providerが再構築されると、再構築が完了するまでそのサブスクリプションは一時停止される
  • Providerが一時停止されると、そのすべてのサブスクリプションも一時停止される
  • Providerをref.listenしているリスナーを手動で一時停止・再開できる

これだけ見ると、リソースが必要なときだけ作られて、不要な時は停止されるので便利に思うのですが、私の場合は次の状況でうまく動かなくなりました。

パターン1:ViewModel

バージョン3からは以下のコードはボタンをタップしても何も起きません。

// ViewModel

class SampleViewModel extends _$SampleViewModel {
  
  void build() {}

  // 何かしらの変更を行うメソッド
  Future<void> updateData() async {
    // 処理内容
  }
}

// 画面
class SampleScreen extends HookConsumerWidget {
  const SampleScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      body: Center(
        child: ElevatedButton(
          onPressed: () async {
            // ViewModelのインスタンスを取得
            final viewModel = ref.read(sampleViewModelProvider.notifier);
            await viewModel.updateData();
          },
          child: const Text('Update'),
        ),
      ),
    );
  }
}

理由としてはviewModelをreadした瞬間にインスタンスがないからです。
これを、Riverpod作者のレミさんは「このproviderに誰も耳を傾けていないから(日本語訳)」と説明しています。

つまり、このProviderをwatch/listenしているものがないので停止している状態となるのです。

これについての解決策は以下の2パターンが存在します。

read -> watchにする

build内でwatchする==耳を傾けている状態 となり取得が可能になります。

// 画面
  
  Widget build(BuildContext context, WidgetRef ref) {
    // まずはwatchする
    final voewModel = ref.watch(sampleViewModelProvider.notifier);

    return Scaffold(
      body: Center(
        child: ElevatedButton(
          onPressed: () async {
            await viewModel.updateData();
          },
          child: const Text('Update'),
        ),
      ),
    );
  }
keepAliveをtrueにする

対象Providerを一度作られたら自動廃棄しないようにしておけば、read でも大丈夫になります。
理屈としては、生成されたものを読み込みにいった場合は keepAlive されているので、いつでも存在していると言うことでしょう。

(keepAlive: true)
class SampleViewModel extends _$SampleViewModel {
  // ...
}

パターン2:await ref.read(provider.future)の対処

Future/StreamなProviderがあるとします。

// 設定を取得する非同期Provider

Future<bool> isEnabled(Ref ref) async {
  // リポジトリーはkeepAlive: true とする
  final repository = ref.read(someRepositoryProvider);
  final isEnabled = await repository.get();
  return isEnabled;
}

上記を何かしらのメソッドやViewModelのビルドでその瞬間の値だけが欲しい場合があります。
この時は watch ではなく read とし、かつ値はそのままだと AsyncValue なので .future する必要があり、以下のようにしていました。

Future<void> doSomething() async {
  // この時点での値を取得する
  final isEnabled = await ref.read(isEnabledProvider.future);
  
  if (isEnabled) {
    // ...
  }
}

しかし、Riverpod 3.x系ではこの方法で値が取れなくなってしまいました。
理由は先ほどと同じく wathc ではないから機能が停止している、と言うことに加えて非同期関連のProviderはたとえ KeepAlive:true にしていたとしても読み取りができなくなっているようです。

これについては作者のレミさんがRiverpodのGitHub上でも言及しております。

https://github.com/rrousselGit/riverpod/issues/3745

レミさんとしては await ref.read(provider.future) で値を取得するのをあまりよしとしておらず、今回のアップデートで調整して結果的に動かなくなったものと思われます。
レミさんが提案する対処法は以下のとおりです。

final sub = ref.listen(provider.future, (p, n){});

try {
  await sub.read();
} finally {
  sub.close();
}

つまり、listen したものを読み込み最後に購読を閉じる、と言うことですがこれを毎回書くのもしんどいので、私は以下のような拡張を書いて使用しています。

extension ReadAsyncOnce on MutationTarget {
  /// 対象 AsyncProvider の「今この瞬間の値」を一度だけ取得する。
  /// - 再計算は行わず、購読→取得→即クローズ
  /// - Riverpod 2.x の `await ref.read(provider.future)` 相当の用途
  Future<T> readAsync<T>(AsyncProviderListenable<T> provider) async {
    // futureプロバイダを一時購読
    final subscription = container.listen(provider.future, (_, __) {});
    try {
      // 既に完了していれば即時で、未完了なら完了まで待って取得
      return await subscription.read();
    } finally {
      subscription.close();
    }
  }
}

使用するときは先ほどの例で行くと以下のとおりです。

Future<void> doSomething() async {
  // この時点での値を取得する
  final isEnabled = await ref.readAsync(isEnabledProvider);
  
  if (isEnabled) {
    // ...
  }
}

StreamProviderの変更関連

ここに関しても大きな変更点がいくつか入っています。

https://riverpod.dev/docs/whats_new#all-updateshouldnotify-now-use-

ざっくりの概要で書くと

  • 同じ値をStreamで流しても通知しなくなった
  • どのような値で通知を流すかはカスタマイズできるようにした
  • 大きなデータをStreamに流すとパフォーマンスに影響が出る可能性がある

また、説明では書いていないのですが、Stream<void> を返すProviderも非対応となりました。
これについては以前から作者のレミさんがあまり推奨しない旨を話されていたので、今回その正式な対応が入ったものと思われます。

https://stackoverflow.com/questions/77492888/should-i-use-provider-instead-of-notifier-with-void-in-riverpod#:~:text=Whether it is a ,void

このことから私は以下のような対応を行いました。

voidを返していたものをboolにかえ、同じ値でも変更を検知するNotifierにする

例えば、API通信を行って処理が完了したことをStreamで配信するStreamをまずはboolに変更します。

class APIService {
  APIService(this.ref);

  final Ref ref;

  /// 同期の進捗を通知するStream
  /// void -> boolに変更
  final _onCompleted = StreamController<bool>.broadcast();

  /// 同期完了時に発火するStream
  /// void -> boolに変更
  Stream<bool> get onCompleted => _onCompleted.stream;

  Future<void> fetch() async {

    // 諸々の通信処理

    // 完了を通知
    // void -> boolに変更
    _onCompleted.add(true);
  }
}

そして、この通信完了を監視するProviderを以前はこう書いていましたが、


Stream<void> onCompleted(Ref ref) {
  final service = ref.read(apiServiceProvider);
  return service.onCompleted;
}

バージョン3.x系からはNotifierに変更します。

(name: 'onCompletedNotifier')
class OnCompletedNotifier extends _$OnCompletedNotifier {
  
  Stream<bool> build() {
    final service = ref.read(apiServiceProvider);
    return service.onCompleted;
  }

  
  bool updateShouldNotify(
    AsyncValue<bool> previous,
    AsyncValue<bool> next,
  ) {
    // エラーやローディング中は通知しない
    if (next.hasError || next.isLoading) return false;

    // 常にtrueを流すので、値の比較はせず、イベントが来たら常に通知する
    return true;
  }
}

重要な点は以下です。

  1. voidは使わず値を必ず返す
  2. Notifierにする
  3. updateShouldNotifyをオーバーライドして通知のカスタマイズをする

今回は完了を知らせるだけなので bool にしましたが、こちらは Stringint などでも問題はありません。
また、同じ値が流れてきた場合も通知させたいのでNotifier化 + updateShouldNotify のオーバライドを行いましたが、前後の値が同じ場合は通知しなくて良いのであれば今まで通りのProviderでも問題ありません。

Streamでオブジェクトや配列などを返さないようにする

以前から私はRiverpodとStreamの相性が良くないと感じることがしばしばありました。
いわゆるasync*を使ってメソッド内で非同期処理をした上でStreamを返す場合などで、前後の値の比較がうまくいかない場合や、場合によってはインスタンスの無限増殖が起こるパターンです。

インスタンスの無限増殖は await for 構文を使うと発生していたので、それを使わなければいいのですが、
いずれにせよ、公式ドキュメントでもStreamでオブジェクトを流す場合にパフォーマンスの悪化を懸念する旨を言及されています。

結論として、私は以下のような方針をとることにしました。

  • Stream は「イベント通知」に専念させる
  • 実際の値の取得は Repository(などのメソッド)で行う
  • 「変更検知用の Provider」を内部で監視する「値取得用の Provider」を作る

この方針にすると、
「変更検知の責務」と「値の取得の責務」が分離され、
Riverpod 3 のライフサイクル変更とも相性がよくなりました。

具体的な例を示したいと思います。
ローカルデータベース,isarはデータベースの変更を検知して、そのオブジェクトを流す仕組みがあります。
それをそのまま流用すると以下のようになります。

class UserRepository {
  UserRepository(this.ref);

  final Ref ref;
  /// 単体streamを取得
  Stream<User?> watchOne(String id) async* {
    final isar = await ref.read(isarProvider.future);
    // idに該当するUserを検索するquery
    final query = isar.userEntitys
        .filter()
        .isarIdEqualTo(fastHash(id));

    // 該当のデータを監視して、変更があったら即時流す
    // watchLazyはStream<UserEntity>が流れてくる
    // それをdomain(User)に変換して流す
    // ※ toDomainの実装は割愛
    yield* query
        .watchLazy(fireImmediately: true)
        .asyncMap((_) async => (await query.findFirst())?.toDomain());
  }
}

// 一つのUserデータを監視して取得するProvider

Stream<UserData?> userData(Ref ref, String id) =>
  ref.read(userRepositoryProvider).watchOne(id);

それを今回からはただ単に変更を流すものと、値を取得するものに分離して作成するようにしました。

まずは変更を監視するだけのクラスの作成とその変更を伝えるNotifierを作成します。

class IsarOnChangedManager {
  IsarOnChangedManager(this.ref);

  final Ref ref;

  /// User単体の変更を監視する
  Stream<bool> watchUser(String id) async* {
    final isar = await ref.readAsync(isarProvider);

    // watchObjectLazyはStream<void>を返すのでboolに置き換える
    yield* isar.userEntitys
        .watchObjectLazy(
          fastHash(id),
          fireImmediately: true,
        )
        .map((_) => true);
  }
}

(keepAlive: true)
IsarOnChangedManager isarOnChangedManager(Ref ref) => IsarOnChangedManager(ref);

(name: 'userOnChangedNotifier')
class UserOnChangedNotifier extends _$UserOnChangedNotifier {
  
  Stream<bool> build(String id) =>
      ref.read(isarOnChangedManagerProvider).watchUser(id);

  
  bool updateShouldNotify(
    AsyncValue<bool> previous,
    AsyncValue<bool> next,
  ) {
    if (next.hasError || next.isLoading) return false;

    // 常に通知する
    return true;
  }
}

次にリポジトリーでは単純に一つのオブジェクトを取得するだけのメソッドを定義します。

/// 単体を取得
Future<User?> findById(String id) async {
  final isar = await ref.readAsync(isarProvider);
  final entity = await isar.userEntitys.get(fastHash(id));
  return entity?.toDomain();
}

最後にProvider内で変更を通知するProviderと取得するメソッドを組み合わせます。


Future<User?> user(Ref ref, String id) async {
  // 変更監視用のNotifierを購読して、変更があればこのプロバイダーがリビルドされる
  ref.watch(userOnChangedNotifier(id));
  return ref.read(userRepositoryProvider).findById(id);
}

こうしてみると実装を複雑にしてしまっている感じにも見られますが、ある意味変更の検知と取得を分けたことで明確になった部分もあると思います。
この点に関しては賛否の分かれるところですが、一つの実装例として見ていただければ幸いです。

テスト

テスト関連は主に追加された便利メソッドを中心にお話ししていきます。

ProviderContainer.test

ユニットテストでは今まで ProviderContainer を作成して以下のように書いていました。

// Riverpod 2.x系まで
void main() {
  late ProviderContainer container;

  setUp(() {
    container = ProviderContainer();
  });

  tearDown(() {
    container.dispose();
  });
}

この container.dispose(); を毎回書くのが紛らわしいので、ヘルパー関数でラップしていたりしました。
今回新しいインターフェースとして ProviderContainer.test が登場しました。
このインターフェースは内部で container.dispose(); を書いてくれています。

やった、もうヘルパー関数はいらない!と、思いきや、先述した retry の問題があります。
テストにおいてもProviderの生成に失敗した場合に retry してしまうので、例えばエラーを流した場合のテストをすると、デフォルトのリトライ10回を行い、タイムエラーとなってテストが失敗してしまいます。
と言うことで、結局私は以下のようなヘルパー関数を作成して、全テストに適用しています。

// Riverpod 3.x系以降のユニットテスト用のヘルパー関数
ProviderContainer createContainer({
  List<Override> overrides = const [],
  List<ProviderObserver> observers = const [],
}) {
  return ProviderContainer.test(
    overrides: overrides,
    observers: observers,
    retry: (retryCount, error) => null, // <<<<< ここの設定が必要!!
  );
}

また、ウィジェットテストでも同様の問題があるため、以下のようなヘルパー関数も使っています。

// Riverpod 3.x系以降のウィジェットテスト用のヘルパー関数
ProviderScope createScope({
  required Widget child,
  List<Override> overrides = const [],
  List<ProviderObserver> observers = const [],
}) {
  return ProviderScope(
    retry: (retryCount, error) => null, // <<<<< ここの設定が必要!!
    overrides: overrides,
    observers: observers,
    child: child,
  );
}

notifierProvider.overrideWithBuild

Notifierの初期状態をオーバーライドするのに専用のインターフェースが追加となりました。
Notifierのモックは非推奨ですが、初期状態はオーバーライドできるようになりました。

(name: 'onCompletedNotifier')
class OnCompletedNotifier extends _$OnCompletedNotifier {
  
  Stream<bool> build() {
    final service = ref.read(apiServiceProvider);
    return service.onCompleted;
  }
}

void main() {
  final container = createContainer(
    overrides: [
          onCompletedNotifier
              .overrideWithBuild((_, __) => Stream.value(true)),
    ],
  );
}

Future/StreamProviderのオーバーライド復活

過去にはあったものですが、復活しました。
これで AsyncValue の細かいオーバーライドができるようになりました。


Future<int> myFutureProvider() async {
  return 42;
}

void main() {
  final container = ProviderContainer.test(
    overrides: [
      myFutureProvider.overrideWithValue(AsyncValue.data(42)),
    ],
  );
}

同じproviderを複数overrideするとエラーになるようになった

以前までは以下のような場合でも動いていましたが、バージョン3.x系からはちゃんとエラーが出るようになりました。

void main() {
  final mockAppSettingRepository = MockAppSettingRepository();

  final container = ProviderContainer.test(
    overrides: [
      appSettingRepositoryProvider.overrideWith((_) => mockAppSettingRepository),
      // 同じプロバイダーをオーバーライドしている
      appSettingRepositoryProvider.overrideWith((_) => mockAppSettingRepository),
    ],
  );
}

エラーログ

Assertion failed: "Tried to override a provider twice within the same container: appSettingRepositoryProvider"

ウィジェットテストでProviderContainerに簡単にアクセスできるようになった

以前までは ProviderContainer にアクセスするには以下のように書く必要がありました。

// BuildContextを取得
final context = tester.element(find.byType(HomeScreen)) as BuildContext;

// ProviderContainerを取得
final container = ProviderScope.containerOf(context);

それがバージョン3.x系以降はこんなにも簡単になりました!

final container = tester.container();

終わりに

Riverpod 3.x系への移行は、valueOrNullvalueといった単純な置き換えから、Providerのライフサイクル変更への対応、Stream周りの根本的な見直しまで、多岐にわたる作業が必要でした。

特にライフサイクル変更は、今まで当たり前に動いていたコードが突然動かなくなるため、最初は戸惑うかもしれません。
しかし、watchへの変更やkeepAliveの活用、readAsync拡張の導入など、対処法を理解すれば確実に対応できます。

また、テスト周りではretry設定の無効化が必須となるため、ヘルパー関数を用意しておくと移行がスムーズです。

今回紹介した内容が、Riverpod 3.x系への移行を検討されている方の参考になれば幸いです。
新機能の恩恵を受けながら、安定したアプリケーション開発を進めていきましょう。

Discussion