💥

【Flutter】Riverpod 3.0.0の変更点をまとめてみた

に公開

Riverpod 3.0のプレリリースが急に降ってきたので3.0.0-dev.15のうち、気になって咀嚼しきれた部分だけまとめてみました。

今月中(2025/05)に正式なリリースとして来るらしいので、それに備えて何が変わったのか見ていきましょう。

AsyncValueクラスのselaed class化

ずっとriverpod.dev-3を使っていたので当たり前になってしまっていましたが、AsyncValueがsealed classに正式になるのは3.0.0からです。今後はswitch - case文で非同期のProviderの値を使用することができるようになります。

final somethingAsync = ref.watch(somethingAsyncProvider);

// 今後、somethingAsync.when... は使うべきではない

return Column(
    children:[
        switch(somethingAsync) {
            AsyncLoading() => const Center(child: CircularProgressIndicator()),
            AsyncData(:final value) => Text(value),
            AsyncError(:final error) => Text('something error happend. $error'),
       },
    ]
); 

Providerでジェネリクスが使用できるように

プロバイダに対してジェネリクスを使用できるようになりました。


List<T> genericsProvider<T>(Ref ref, List<T> list) {
  return [...list.reversed];
}


class SomethingProvider extends _$SomethingProvider {
  
  void build() {
    final list = ref.watch(genericsProviderProvider<String>(['a', 'b', 'c']));
    final list2 = ref.watch(genericsProviderProvider<int>([1, 2, 3]));

    if (kDebugMode) print('list: $list, list2: $list2');
  }
}

ChangeNotifierProvider, StateProvider, StateNotifierProviderのレガシー化

Riverpod 3.0以降では、これらのProviderを使用するにはpackage:flutter_riverpod/legacy.dartを明示的にインポートしなければならなくなりました。

import 'package:flutter_riverpod/legacy.dart';

// riverpod_annotationを使用せずにChangeNotifierProviderを使用するときにおいては、
// `flutter_riverpod/legacy.dart`を別途インポートする必要があります。
final focusNodeProvider = ChangeNotifierProvider<FocusNode>((ref) {
  return FocusNode();
});

自動リトライ機構

自動でリトライする機構が生まれました。Retry #3623

下記のようなコードが従来では一度失敗すればそのまま失敗していました。Riverpod 3.0では一度失敗した時点でAsyncErrorが返されますが、裏では成功するまでリトライが行われ続けます。
200ミリ秒ごとに試行され、6回目まで2倍ごとに増えていき、6.4秒で打ち止めし、以降は6.4秒間隔で成功するまでリトライされ続けます。


var _count = 0;


Future<String> retryExample(Ref ref) async {
  await Future.delayed(const Duration(seconds: 2));
  _count++;

  // 5回に1回成功する
  if (DateTime.now().millisecondsSinceEpoch % 5 != 0) {
    if (kDebugMode) print('$_count: Failed');
    throw Exception('Failed');
  }
  if (kDebugMode) print('$_count: Success');
  return 'Hello';
}

class RetryExample extends ConsumerWidget {
  const RetryExample({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncValue = ref.watch(retryExampleProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Retry Example')),
      body: switch (asyncValue) {
        AsyncData(:final value) => Text(value),
        AsyncLoading() => const CircularProgressIndicator(),
        AsyncError(:final error) => Column(
            children: [
              Text(error.toString()),
              ElevatedButton(
                onPressed: () => ref.refresh(retryExampleProvider),
                child: const Text('Retry'),
              ),
            ],
          ),
      },
    );
  }
}

既存のプロジェクトなどではDioなどでリトライする機構が入っているケースも多く、意図しない再実行を生む可能性があります。これをやめるにはProviderScopeにてリトライ機構を明示的に停止する必要があります。

ProviderScope(
    retry: (retryCount, error) => null,
    child: ...
);

ところで、errorにはなんのエラーかが入ってくるので、「特定のエラーでは再実行しない」といったことも当然できます。活用方法次第ですかね。

また、個別にリトライを定義することも可能で、@Riverpodのアノテーションにretryの引数を渡せるようになりました。

ref.listenManualの一時停止・再開機構

恐縮ながらref.listenしか使ったことがなく、ref.listenManual自体いまさっき初めて知ったのですが……

ref.listenManualを用いるときに一時停止や再開ができるようになりました。今まではdispose(close)しかなかったのが、watchしている状態は継続したまま画面の状態は更新しないということができるようになりました。

ただし、これは下記の例でいえば、Timerの処理自体を停止しているわけではなく、その反映をしていないだけです。



class PauseResumeExample extends _$PauseResumeExample {
  Timer? _timer;

  
  int build() {
    _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
      state++;
    });
    ref.onDispose(() {
      _timer?.cancel();
    });
    return 0;
  }
}

class PauseResumeExampleWidget extends HookConsumerWidget {
  const PauseResumeExampleWidget({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final count = useState<int>(0);
    final isPaused = useState(false);

    final subscriptionState = useState<ProviderSubscription<int>?>(null);

    useEffect(
      () {
        final subscription = ref.listenManual(
          pauseResumeExampleProvider,
          (previous, next) {
            if (next == previous) return;
            count.value = next;
          },
        );
        subscriptionState.value = subscription;
        return () => subscription.close();
      },
      const [],
    );

    return Scaffold(
      appBar: AppBar(title: const Text('Pause Resume Example')),
      body: Column(
        children: [
          Text(
            'Count: ${count.value}',
          ),
          ElevatedButton(
            onPressed: () {
              final subscription = subscriptionState.value;
              if (subscription == null) return;
              subscription.isPaused
                  ? subscription.resume()
                  : subscription.pause();
              isPaused.value = subscription.isPaused;
            },
            child: Text(
              isPaused.value ? 'Resume' : 'Pause',
            ),
          ),
        ],
      ),
    );
  }
}

Ref.mountedの追加

非同期処理後のcontextがその場面で生きているかが不明瞭なため、

if(!context.mounted) return;

とすることはしばしばあるかと思いますが、Provider中のRefも同様に、そのRefが既に破棄されているかどうかを確かめることができるようになりました。これがref.mountedの追加です。

プロバイダ自身やあるいはProviderContainerそのものが破棄されている場合において、ref.mounted以外のすべてのRefやstateに対する操作は例外となります。

※ ただし実際にflutter_riverpodで実際にref.mounted = falseとなる状態を起こした後にstateの操作を行っても特にエラーになっていないような気がします。これが過渡期によるバグなのか私の勘違いなのか、この部分において「正確な」説明にはまだもうちょっと深追いが必要です……

await Future.delayed(const Duration(seconds: 5));
if(!ref.mounted) return;
state = AsyncData(...)

かなり破壊的な変更なので、予期しない挙動を引き起こすかもしれません。そのため、過渡期の一旦の逃げのような解決策があります。

ref.unsafe_checkIfMounted = false;

を用いることで、このref.mountedのチェックを行わないようにすることができます。

データの永続化のサポート

Providerのデータの永続化がRiverpodで統合してサポートされるようになりました。sqfliteのパッケージがAndroid, iOS, macOSにしか対応していないので、LinuxやWindowsでは動作しませんが、下記のようにfreezedのクラスを特定のキーに保持して…というようなことができるようになりました。

これにおいて、sqflite以外の方法でも永続化するための方法を別記事【Flutter】Riverpod3.0からの永続化に独自の保存領域を使うに記載しました。

5/9時点ではriverpod_sqfliteが3.0.0-dev.14に固定されているので、dev.15にすると依存関係の解決に失敗します。

import 'dart:convert';

import 'package:hooks_riverpod/experimental/persist.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_sqflite/riverpod_sqflite.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as path;
import 'package:freezed_annotation/freezed_annotation.dart';

part 'persistent.freezed.dart';
part 'persistent.g.dart';


Future<JsonSqFliteStorage> storage(Ref ref) async {
  return JsonSqFliteStorage.open(
    path.join(await getDatabasesPath(), 'example.db'),
  );
}


abstract class Todo with _$Todo {
  const factory Todo({
    required String id,
    required String title,
    required bool completed,
  }) = _Todo;

  factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}


class TodoNotifier extends _$TodoNotifier
    with Persistable<List<Todo>, String, String> {
  
  Future<List<Todo>> build() async {
    await persist(
      key: 'todos',
      storage: ref.watch(storageProvider.future),
      encode: jsonEncode,
      decode:
          (json) => [
            ...(jsonDecode(json) as List).map((e) => Todo.fromJson(e)),
          ],
    );

    return state.value ?? [];
  }
}

Scoped Providerの依存の記述方式の変更

これまで、Scoped Providerを使う際にProvider同士では依存を記述する必要がありました。Riverpod 3.0以降では、ウィジェットにもアノテーションによる依存の記述が必要になります。

これはなにかのコードがこの部分で生成されるというよりかは、静的解析たるlintに対する宣言です。

Scoped Provider自体は、従来のproviderパッケージのユースケースのうち、このウィジェット以下はこの値…といった引数のレースを避けるために前から使用できるものです。

import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

// 実験的機能なのでexperimentalが必要
import 'package:riverpod_annotation/experimental/scope.dart';

import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'dependencies.g.dart';

(dependencies: [])
String selectedBookID(Ref ref) => throw UnimplementedError();

class DependencySample extends StatelessWidget {
  const DependencySample({super.key});

  
  Widget build(BuildContext context) {
    return ProviderScope(
      overrides: [selectedBookIDProvider.overrideWithValue('12345')],
      child: InternalDependencySample(),
    );
  }
}

//Providerでないクラスに対し、ScopedProviderを使う場合は、そのクラス自体に@Dependenciesを付与する必要がある
([selectedBookID])
class InternalDependencySample extends ConsumerWidget {
  const InternalDependencySample({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final id = ref.watch(selectedBookIDProvider);
    return Text('Book ID: $id');
  }
}

ミューテーションのサポート

これがかなり大きな変更で、かなり革命的な機能追加です。これもexperimentalとしてマークされています。

この機能は、Riverpod 3.0以降で「副作用を伴う状態管理」で最も推奨されるべき手段の一つとなりえます。従来のRiverpodではスピナーの表示とエラーハンドリングのドキュメントを見るとわかるように、何らかボタンを押したそのときの画面の状態管理というのは、AsyncSnapshotをごにょごにょと触るかflutter_hooksを使うかしかありませんでした。かなりよく起きるユースケースであるにもかかわらず、微妙なアプローチばかりだったのです。

しかしながら、Riverpod 3.0以降ではこの副作用を伴う状態管理に対し、エレガントなアプローチが導入されました。

さてまずこの機能を使うには、

import 'package:riverpod_annotation/experimental/mutation.dart';

を明示的にインポートする必要があります。

下記のように非同期のメソッドに対して@mutationとマークします。


class Mutation extends _$Mutation {
  
  Future<int> build() async {
    return 0;
  }

  
  Future<int> increment() async {
    final value = await future;
    await Future.delayed(const Duration(seconds: 5));
    state = AsyncData(value + 1);
    return value + 1;
  }
}

そして、下記のようにProviderが持つ更にそのmutation対象をwatchすることでーーー


class MutationExample extends ConsumerWidget {
  const MutationExample({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final increment = ref.watch(mutationProvider.increment);
    return Column(
      children: [
        switch (increment.state) {
          MutationIdle() => ElevatedButton(
            onPressed: () => increment.call(),
            child: const Text('Increment'),
          ),
          MutationPending() => const CircularProgressIndicator(),
          MutationSuccess(:final value) => Text('Success! $value'),
          MutationError() => const Text('Error!'),
        },
      ],
    );
  }
}

「ボタンを押す前」「操作中」「完了時」「エラー時」の4つの状態をRiverpodで管理できるようになります。

これによって、副作用を伴う状態管理をProviderに統合できるようになります。今後はなるべくこの方法で状態管理をしていきたいですね。

絶賛議論中「codegenやめる?」

Unified syntax for providers, without code-generation #4008はただならぬ大規模変更を予期させるIssueです。将来的にはcodegenやめるかもみたいな話が絶賛議論中のように見え、これは3.1.0で実験的機能として追加されることが示唆されています

その他

autoDisposeの書き方が変わったのは、riverpod_generatorに取って代わった現在ではそこまで大きなものではないかも…

Discussion