RiverpodのProviderと仲良くなるために

2024/07/05に公開

Riverpodは、筆者の一番好きなFlutterの状態管理ライブラリです。
この記事では、筆者が見聞きする中で、「RiverpodのProviderにおいて、ここに注目するとうまく整理できるのではないか?」と考えていることをまとめます。

はじめに

本記事においては、riverpod_generatorが導入されていることを前提とします。

そのほか、筆者がどのようにRiverpodを捉えているかについては、過去に書いた記事をご参照ください。

https://zenn.dev/koji_1009/articles/18a8a54b615ae7

https://zenn.dev/koji_1009/articles/fa972b070eb2f4

変化の伝播

あるProviderが変化し、それを参照しているProviderが再計算されることを考えます。例として、1秒おきに更新されるProviderと、その日付を文字列かするProviderを示します。


DateTime current(
  CurrentRef ref,
) {
  final timer = Timer(
    const Duration(
      seconds: 1,
    ), () {
      ref.invalidateSelf();
    },
  );

  ref.onDispose(timer.cancel);
  return DateTime.now();
}


String currentIso8601(
  CurrentIso8601Ref ref,
) {
  final current = ref.watch(currentProvider);
  return current.toIso8601String();
}

currentProviderが1秒おきに更新されるたび、currentIso8601Providerも再計算されます。このため、currentIso8601Providerを参照しているWidgetは1秒おきに再描画されます。結果、毎秒日付が更新されるWidgetを実現できる、というわけです。

この記事では「あるProviderが変化したとき、参照しているProviderが再計算される」という挙動を、変化の伝播と呼ぶことにします。この変化の伝播について考えることで、「Riverpodについて理解が進み仲良くなれるのではないか」と考えています。これが、本記事の趣旨です。

Functional ProviderとClass-based Provider

この記事で頻出するため、まず、Functional ProviderClass-based Providerの2つの用語を確認します。

Functional ProviderClass-based Providerという言葉は、次のリンク先に存在します。

https://riverpod.dev/docs/concepts/about_code_generation#defining-a-provider

この2つの区分は、riverpod_generatorを利用していないと、いまいちピンとこないかもしれません。@riverpodアノテーションを付与する対象が、FunctionなのかClassなのかによって、riverpod_generatorが生成するコードは変わります。前者はProvidierであり、後者はNotifierです。

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

この用語は、上記Issueにて提案されました。Statelessの代わりにFunctionalStatefulの代わりにClass-basedとなっています。Functionalは状態を持たない、Class-basedは状態を持つという意味合いでもあります。


riverpod_generatorの実装も確認してみると、functional_provider.dartclass_based_provider.dartというファイルが存在します。コードを読んでみると、エラー時にfunctionalclass-basedを含むエラーメッセージが出力されるようです。もしかすると、コンソール上で文字を見かけた方もいるかもしれません。
[1]

https://github.com/rrousselGit/riverpod/blob/riverpod_generator-v2.4.2/packages/riverpod_generator/lib/src/templates/functional_provider.dart

https://github.com/rrousselGit/riverpod/blob/riverpod_generator-v2.4.2/packages/riverpod_generator/lib/src/templates/class_based_provider.dart


機能のシンプルさから言うとFunctional Providerの方がシンプルです。しかし、シンプルであるが故に、利用されるパターンが多くなります。このため、変化の伝播の観点から整理しようとすると、Class-based Providerの方がわかりやすいでしょう。

以上の理由から、Class-based Providerを押さえた上で、Functional Providerを整理していきます。

Class-based Provider

最もシンプルなClass-based Providerから確認しましょう。初期値を42とし、ユーザーの操作により状態を更新するProviderが、次のコードです。


class Answer extends _$Answer {
  
  int build() => 42;

  void initialize() {
    state = 42;
  }

  void update(
    int newValue,
  ) {
    state = newValue;
  }
}

生成されるanswerProviderは、watchメソッドを利用して状態を参照できます。initializeメソッドを利用すること(値を外部から指定することなく)状態を更新したり、updateメソッドを利用することで(値を外部から指定して)状態を更新できます。

先述の通り、Class-based Provider状態を持つProviderです。

この状態が更新されていると、参照しているConsumerやProviderが再計算され、更新が反映されます。つまり変化の伝播自身が更新された時に引き起こすことができるProviderです。

Functional Provider

最もわかりやすいFunctional Providerは、定数を返すProviderです。


String question(
  QuestionRef ref,
) => 'What is the answer?';

Functional Providerとして定義したquestionProviderは、常に'What is the answer?'を返します。定数を返しているので、変化の伝播を引き起こすことはありません。ほかにも変化の伝播を引き起こさないケースがあるのですが、それらは後ほどまとめて整理します。

Functional Providerは、Class-based Providerと異なり、状態を持ちません。このため、シンプルに考えれば変化の伝播を起しません。が、たとえば次のような条件に合致する時、変化の伝播を起こすProviderとなります。

  1. 変化の伝播を引き起こすProviderを参照している
  2. FutureStreamを返すProviderを参照している

変化の伝播を引き起こすProviderを参照している

Functional Provider変化の伝播を引き起こすProviderを参照しているケースです。変化の伝播は波及するので、変化の伝播を引き起こすProviderを参照しているProviderも、変化の伝播を引き起こすProviderとなります。


String question(
  QuestionRef ref,
) => 'What is the answer?';


class Answer extends _$Answer {
  
  int build() => 42;

  void initialize() {
    state = 42;
  }

  void update(
    int newValue,
  ) {
    state = newValue;
  }
}


String questionAndAnswer(
  QuestionAndAnswerRef ref,
) {
  final question = ref.watch(questionProvider);
  final answer = ref.watch(answerProvider);
  return '$question -> $answer';
}

questionAndAnswerProviderは、questionProvideranswerProviderを参照しています。このためanswerProviderが更新されると、questionAndAnswerProviderも再計算されます。

対して当然ではありますが、変化の伝播を引き起こさないProviderを参照している場合には、変化の伝播の性質はProviderに与えられません。answerProviderを定数を返すFunctional Providerとして、定義し直してみます。


String question(
  QuestionRef ref,
) => 'What is the answer?';


int answer(
  AnswerRef ref,
) => 42;


String questionAndAnswer(
  QuestionAndAnswerRef ref,
) {
  final question = ref.watch(questionProvider);
  final answer = ref.watch(answerProvider);
  return '$question -> $answer';
}

この例では、questionAndAnswerProvider'What is the answer? -> 42'を返します。answerProviderquestionProviderを更新できないので、値は変化しません。


Functional Providerの性質として、Providerを合成する機能がある、と筆者は理解しています。

FutureStreamを返すProviderを参照している

FutureStreamを返すProviderを参照している場合、Providerは変化の伝播を引き起こします。これは、FutureStreamが更新されるたびに、Providerが再計算されるためです。


Stream<User?> user(
  UserRef ref,
) => FirebaseAuth.instance.authStateChanges();

authStateChangesは、ログイン状態が変更されるたびに、Userを返します。userProviderはログイン状態が変更されるたびに再計算されることとなり、変化の伝播を引き起こすProviderです。


Riverpodが便利な理由の1つに、RiverpodがFutureStreamをProviderに変換する機能があります。FutureProviderStreamProviderは、FutureStreamをProviderに変換し、値の更新を変化の伝播として扱うことができます。

RiverpodのProviderは、Flutterにおいては、最終的にWidgetのbuildメソッドで参照されます。このため、FutureStreamFutureProviderStreamProviderに変換すると、Providerをbuildメソッド内でwatchできるようになります。FutureStreamで得られた値を、ProviderとしてWidgetに反映できるわけです。

なお、Flutterの標準WidgetであるFutureBuilderStreamBuilderを利用することで、同様のことは実現できます。このため、Riverpodが過剰な機能を提供しているように思えるかもしれません。

筆者の意見としては、「FutureStreamを組み合わせないアプリケーションでは不要かもしれない」と感じます。

大抵のアプリケーションでは、Widgetのが1つのFutureStreamを参照するにとどまりません。適切なロジックを組み上げようとすると、ChangeNotifierValueNotifierの中で、FutureStreamを参照する必要が生じます。このFutureStream、そしてChangeNotifierを複数回組み合わせようとすると、そこそこ難解なコードを書く必要が生じます。[2]


RiverpodはFutureStreamをProviderに変換することで、FutureStreamで表現される値の変更をProviderとして表現します。Provierの合成は、Functional Providerを噛ませることで、簡単に合成できます。結果、FutureStreamの合成をProviderの合成として表現でき、シンプルにロジックを記述できます。

以下では、このProviderの組み合わせ方を考察していきます。

Providerの組み合わせ方を考慮する

より具体的な使い方を考えながら、Providerの組み合わせ方を確認します。筆者が思いつく典型的なパターンなので、他の事例もあるかもしれません。[3]

初期値を参照するClass-based Provider

Class-based Providerの頻出パターンとして、他のProviderから初期値を取得するケースを考えます。


String question(
  QuestionRef ref,
) => 'What is the answer?';


class Answer extends _$Answer {
  
  int build() => 42;

  void initialize() {
    state = 42;
  }

  void update(
    int newValue,
  ) {
    state = newValue;
  }
}


class QuestionAndAnswer extends _$QuestionAndAnswer {
  
  ({String question, String answer}) build() {
    final question = ref.watch(questionProvider);
    final answer = ref.watch(answerProvider);
    return (
      question: question,
      answer: answer,
    );
  }

  void update(String newAnswer) {
    state = (
      question: state.question,
      answer: newAnswer,
    );
  }

  void save() {
    ref.read(answerProvider.notifier).update(state.answer);
  }
}

questionAndAnswerProviderは、初回のアクセス時にquestionProvideranswerProviderを参照して、初期値を取得します。初期値を設定した後は、updateメソッドを利用することで自身の状態を更新します。この状態はsaveメソッドを利用することで、answerProviderに反映されます。

一方で、questionAndAnswerProvideranswerProviderが更新されると、初期化されてしまいます。というのも、answerProviderが更新されると、questionAndAnswerProviderも再計算されるためです。この挙動は、時たま上手に利用するケースはありますが、基本的には不具合を引き起こすものになります。

Class-basaed Providerにおいて変化の伝播を持つProviderを参照するのは、初期値を取得する場合に絞った方がよいでしょう。また、そう設計していない場合には、参照中のProviderを更新するのは避けた方が良いでしょう。[4]

Singleton objectを返すProvider

定数ならコードに書けばいいので、実際にはSingletonなobjectを返すProviderの方が馴染み深いはずです。SharedPreferencesFirebaseAuthをProvider経由で取得する場合、次のような実装になります。


Future<SharedPreferences> prefs(
  PrefsRef ref,
) async => await SharedPreferences.getInstance();


FirebaseAuth auth(
  AuthRef ref,
) => FirebaseAuth.instance;

これらのProviderは、SingletonなObjectを返します。Singletonは常に同じインスタンスを返す設計パターンです。このため、prefsProviderauthProvider変化の伝播を引き起こすことはありません。

一方で、SharedPreferencesやFirebaseAuthが管理している状態は、アプリケーションの利用中に変化します。このため、任意のイベントが発生した時に、変化の伝播を引き起こす必要があります。この引き起こし方を、2つ紹介します。

Singleton objectとClass-based Providerとの組み合わせ

Class-based Providerでラップすることで、変化の伝播を引き起こすことができます。


class UserName extends _$UserName {
  
  String build() {
    // 色々とややこしいので、prefsProviderをsyncで取得できるように調整
    return ref.watch(
      prefsProvider.select(
        (value) => value.getString('name'),
      ),
    );
  }

  Future<void> update(String name) async {
    final prefs = ref.read(prefsProvider);
    prefs.setString('name', name);

    state = name;
  }
}

UserNameは、初回のアクセス時にprefsProviderを参照してnameを取得します。更新時にはprefsProviderに値を保存した上で、stateを更新します。

結果として、userNameProviderは、nameを管理するClass-based Providerとなります。Class-based Providerであるため、stateが更新されるたびに、Providerをwatchしている箇所が再計算されます。

なおSharedPreferencesに同期的にアクセスするには、overrideWithValuerequiredValueを利用する方法があります。[5]

値の変化をStreamで通知する

firebase_authauthStateChangescloud_firestoresnapshotsなどの実装です。これらのメソッドは、SingletonなObjectが管理している状態の変化をStreamで返却しています。


Stream<User?> user(
  UserRef ref,
) => FirebaseAuth.instance.authStateChanges();

先述の通り、Streamを返すProviderは変化の伝播を引き起こします。Singleton objectそのものではなくobjectが管理する状態をProviderとすれば、通常のFunctional Providerと同様に利用できます。

アクセス時の最新の値を返すProvider

典型的な例として、サーバーからのレスポンスを返すProviderを考えます。他の例としては、先ほど示したcurrentProviderがあります。


Future<Plan> plan(
  PlanRef ref,
) async {
  final response = await http.get(
    Uri.parse('https://example.com/plan'),
  );

  return Plan.fromJson(
    jsonDecode(response.body) as Map<String, dynamic>,
  );
}

planProviderは、サーバーにあるPlanを取得するProviderです。このProviderはアクセス時にリクエストを送信し、最新のPlanを返します。このProviderはFutureを返すため、変化の伝播を引き起こします。ただ通常は、次のようにawaitされて参照されるでしょう。


Future<Plan> recommemedPlan(
  RecommendedPlanRef ref,
) async {
  final plan = await ref.watch(planProvider.future);
  return plan.recommendedPlan;
}

このrecommemedPlanProviderは、planProviderのmap処理です。Planが変化したならば、その内容を反映したいProviderになります。つまりrecommendedPlanProviderが最新のPlanを反映したい時には、planProviderを再実行し、最新のPlanを取得する必要があります。

この時に利用できるのがinvalidateメソッドです。

https://pub.dev/documentation/riverpod/latest/riverpod/Ref/invalidate.html

invalidateメソッドは、Providerを強制的に再計算させるためのメソッドです。強制的に再計算させられるということは、Providerに変化が生じ、変化の伝播が引き起こされることを意味します。

Text(
  'Get latest plan',
  onPressed: () {
    ref.read(planProvider).invalidate();
  },
)

invalidateは、ネットワークエラーによりPlanの取得に失敗した場合にも利用できます。そのほかには、定数を返すProviderのような、通常変化の伝播を引き起こさないProviderを、一時的に変化の伝播を引き起こすProviderに変更する際にも利用できます。

クラスを返すProvider

レイヤードアーキテクチャにおける、RepositoryをProviderとして表現する場合、2つのケースが考えられます。1つはconstな値、つまり定数として返すケースです。


UserRepository userRepository(
  UserRepositoryRef ref,
) => const UserRepository();

UserRepositoryconstな値として返されるため、変化の伝播を引き起こしません。このようなProviderは、筆者の理解では、アプリケーション内でDIを実現するために利用されます。


対して、次のように(たとえば)Userの認証状態に応じて、UserRepositoryを返すProviderも存在します。


UserRepository userRepository(
  UserRepositoryRef ref,
) {
  final token = ref.watch(
    userProvider.select(
      (value) => value.token,
    ),
  );

  return UserRepository(
    token: token,
  );
}

この実装は、UserRepositoryUserの認証状態に応じて変化します。ログアウトされた場合には、tokenがnullや空文字に変化します。こうなると、userRepositoryProvider変化の伝播を引き起こします。

結果として、userRepositoryProviderは、Userの認証状態に応じて変化するProviderとなります。


Future<UserInfo> userInfo(
  UserInfoRef ref,
) async {
  final userRepository = ref.watch(userRepositoryProvider);
  final userInfo = userRepository.userInfo();
  ref.keepAlive();

  return userInfo;
}

上記のuserInfoProviderは、userRepositoryProviderが更新されるたびに再計算されます。userInfoProviderが再計算されるタイミングを考えると、ログアウトや別ユーザーとしてログインした時に、再計算されることが期待されます。

結果として、今ログインしているユーザーの情報を取得するProviderとして利用でき、アプリケーションの状態を簡潔に表現できます。

おわりに

Riverpodが提供するProviderそのものは、非常にシンプルです。問題はProviderを利用するシーンが多様であり、また、複雑である点です。このため、アプリケーションで採用しているアーキテクチャの複雑さが、一見するとRiverpodの複雑さに見えてしまうこともあります。

本記事が、適切にProviderを利用する助けになれば幸いです。

脚注
  1. 筆者はこのエラーメッセージを見た覚えがなく、今回「こんなメッセージが出るんだ」と知りました。 ↩︎

  2. 実装したことがある方には共感いただけるのではないかなと。 ↩︎

  3. 筆者の意見としては、おおよそこのパターンにおまりますし、収めた方が管理しやすいと思います。 ↩︎

  4. なお、なんらかのエラーが発生した時には、逆に参照しているProviderも初期化する必要がある点にも注意が必要です。 ↩︎

  5. 本筋から外れるため、ここでは紹介しません。 ↩︎

GitHubで編集を提案

Discussion