[読書会]Riverpod.dev/docs 翻訳及び補足(All Providers)

Riverpod.dev/docs(開始URL: https://riverpod.dev/docs/introduction/why_riverpod )には日本語訳ページ(/ja)も部分的には既にあるのですが、その部分はそのまま参照して、その他の英語訳のままのページについては自力で翻訳および解説を補足してみました。まだ工事中ですが最終的に満足ができたら上記サイトの翻訳ページに献上できないかな等という目標もあります。

本ブログの(上記Riverpod.dev/docsの)翻訳および解説の目次頁(構成上のトップページ)は次のURLになります。

本頁は、上記のうちの、「All Providers」の章の翻訳および解説頁になります。

All Providers

Provider

Provider はプロバイダの中で最もベーシックなプロバイダであり、値を同期的に生成してくれます。

一般的には次のような用途で使われます。

  • 計算結果をキャッシュするため。
  • 他のプロバイダに値(例えば Repository や HttpClient のインスタンス)を公開するため。
  • テスト実施時やウィジェット構築時に値をオーバーライドするため。
  • select を使わずにプロバイダやウィジェットの更新の条件を限定するため。

Provider を使って計算結果をキャッシュする

Provider ref.watch と組み合わせることで、同期的な処理の結果をキャッシュする強力なツールとなります。

例えば、Todo リストにフィルタを適用するような場合です。 この種のフィルタリングは負荷が若干高くなる可能性があるため、画面が再描画するたびにフィルタが適用されてしまうようなことは避けたいものです。 そこで登場するのが Provider です。Provider にフィルタリングを任せて、その計算結果をキャッシュしましょう。

Todo リストを管理する、次のような StateNotifierProvider があるとします。

Todo管理プロバイダ(データモデルクラス、状態管理クラス、プロバイダ):
//Todoデータモデルクラス:
class Todo {
  Todo(this.description, this.isCompleted);
  final String description;
  final bool isCompleted;
}
//Todo状態管理クラス
class TodosNotifier extends StateNotifier<List<Todo>> {
  TodosNotifier() : super([]);

  void addTodo(Todo todo) {
    state = [...state, todo];
  }
  // TODO "removeTodo" のような他のメソッドを追加する
}
//Todoプロバイダ
final todosProvider = StateNotifierProvider<TodosNotifier, List<Todo>>((ref) {
  return TodosNotifier();
});

そしてProviderを使って Todo リストをフィルタリングし、完了タスクのみを残してリストを公開します。

完了Todoプロバイダ:

final completedTodosProvider = Provider<List<Todo>>((ref) {
  // todosProvider から Todo リストの内容をすべて取得
  final todos = ref.watch(todosProvider);

  // 完了タスクのみをリストにして値として返す
  return todos.where((todo) => todo.isCompleted).toList();
});

ウィジェットは completedTodosProvider を監視することで、完了タスクのみを UI として表示することができます。

フィルタリングの計算結果をキャッシュすることができました。:

Consumer(builder: (context, ref, child) {
  final completedTodos = ref.watch(completedTodosProvider);
  // TODO ListView/GridView/... を使って Todo リストを表示する /* SKIP */
  return Container();
  /* SKIP END */
});

これでフィルタリングの計算結果をキャッシュすることができました。

completedTodosProvider が公開する完了タスクのリストは、何度値を取得しようが、新たに Todo が追加・削除・更新されるまで再評価されることはありません。

また、Todo リストの内容が変わった際に手動でキャッシュを無効化する必要がない点にお気づきでしょうか。 ref.watch があるおかげで、Provider は値を再評価するタイミングを自動検知することができるのです。

Provider を使ってプロバイダやウィジェットの更新の条件を限定する

Provider が他のプロバイダと異なる点は、ref.watch を使うなどして Provider の値が再評価された際に、 その値が以前の値と変わらない場合は、値を監視する別のプロバイダもしくはウィジェットに通知しないという点です。 つまり、そのプロバイダもしくはウィジェットが更新されることはないということです。

この特性を活用できる例としては、ページネーションが施されたページの「戻る/次へ」ボタンの有効・無効の切り替えが挙げられます。

ここでは「戻る」ボタンにのみフォーカスしてサンプルコードをご紹介したいと思います。 まず、現在のページインデックスを取得し、それが 0 に等しい場合はボタンが無効化するようなウィジェットを準備します。

コードは次のようになります。

Dart

final pageIndexProvider = StateProvider<int>((ref) => 0);

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    //現在のページが最初のページ(0)でない場合、戻るボタンを有効にする
    final canGoToPreviousPage = ref.watch(pageIndexProvider) != 0;

    void goToPreviousPage() {
      ref.read(pageIndexProvider.notifier).update((state) => state - 1);
    }

    return ElevatedButton(
      onPressed: canGoToPreviousPage ? goToPreviousPage : null,
      child: const Text('previous'),
    );
  }
}

しかし、このコードには問題があります。それは現在のページが変わるたびに「戻る」ボタンが更新されてしまうことです。 ここはボタンの有効・無効が切り替わるときにのみ、ボタンが更新されるのが理想的ですよね。

この問題の要因は、ユーザが前のページに戻れるか否かの指標を「戻る」ボタンのウィジェットが直接計算してしまっていることにあります。

よって、この問題を解決するにはロジックをウィジェットから抽出して Provider に計算を任せる必要があります。

Dart

final pageIndexProvider = StateProvider<int>((ref) => 0);

// ユーザが前のページに戻れるかどうかを計算するプロバイダ
final canGoToPreviousPageProvider = Provider<bool>((ref) {
  return ref.watch(pageIndexProvider) != 0;
});

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 新しく作成したプロバイダを監視
    // ウィジェットはこれでもう前のページに戻れるかの計算をする必要がない
    final canGoToPreviousPage = ref.watch(canGoToPreviousPageProvider);

    void goToPreviousPage() {
      ref.read(pageIndexProvider.notifier).update((state) => state - 1);
    }

    return ElevatedButton(
      onPressed: canGoToPreviousPage ? goToPreviousPage : null,
      child: const Text('previous'),
    );
  }
}

このような小さなリファクタリングを行うことで、PreviousButton ウィジェットはページインデックスが変わっても更新されることがなくなりました。

ページインデックスが変わると canGoToPreviousPageProvider の値は再評価されますが、 値が直前に公開したものと変わらなければ、PreviousButton は更新されません

これでボタンのパフォーマンスが改善し、さらにロジックとウィジェットを分離して管理することができるようになりました。 これは Provider のユニークな特性のおかげによるものです。

(Async)NotifierProvider

NotifierProviderは、Notifierをリッスンし、公開するために使用されるプロバイダです。
AsyncNotifierProviderは、AsyncNotifierをリッスンし、公開するために使用されるプロバイダです。
AsyncNotifierは、非同期に初期化できるNotifierです。
(Async)NotifierProviderと(Async)Notifierは、ユーザーとの対話に反応して変更される可能性のある状態を管理するためのRiverpodの推奨ソリューションです。

通常、次の目的で使用されます。

  • カスタム イベントに反応した後、時間の経過とともに変化する可能性のある状態を公開します。
  • 一部の状態を変更するためのロジック (別名「ビジネス ロジック」) を 1 か所に集中させ、時間の経過とともに保守性を向上させます。

使用例として、NotifierProvider を使用して todo リストを実装できます。そうすることで、addTodo などのメソッドを公開して、UI がユーザー インタラクションの Todo リストを変更できるようになります。

Todoデータモデルクラス(@freezed使用),Notifier状態管理クラス・NotifierProviderプロバイダ(@riverpod使用):

@freezed
class Todo with _$Todo {
  factory Todo({
    required String id,
    required String description,
    required bool completed,
  }) = _Todo;
}

//これにより、Notifier と NotifierProvider が生成されます。
//NotifierProvider に渡される Notifier クラス。
//このクラスは、その「state」プロパティの外部に状態を公開すべきではありません。
//つまり、パブリックゲッター/プロパティはありません!
//このクラスのパブリック メソッドを使用すると、UI が状態を変更できるようになります。
//最後に、todosProvider(NotifierProvider) を使用して、
//Todos クラスに応じて、UI が状態を変更します。
@riverpod
class Todos extends _$Todos {
  @override
  List<Todo> build() {
    return [];
  }

  void addTodo(Todo todo) {
    state = [...state, todo];
  }
  
  void removeTodo(String todoId) {
    state = [
      for (final todo in state)
        if (todo.id != todoId) todo,
    ];
  }

  void toggle(String todoId) {
    state = [
      for (final todo in state)
        if (todo.id == todoId)
          todo.copyWith(completed: !todo.completed)
        else
          todo,
    ];
  }
}

NotifierProvider を定義したので、それを使用して UI の ToDo リストを操作できます。

(NotifierProvider を使用して) UI の ToDo リストを操作できます。:

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {

    List<Todo> todos = ref.watch(todosProvider);

    return ListView(
      children: [
        for (final todo in todos)
          CheckboxListTile(
            value: todo.completed,
            onChanged: (value) =>
                ref.read(todosProvider.notifier).toggle(todo.id),
            title: Text(todo.description),
          ),
      ],
    );
  }
}

使用例として、AsyncNotifierProvider を使用してリモート todo リストを実装できます。そうすることで、addTodo などのメソッドを公開して、UI がユーザー インタラクションの Todo リストを変更できるようになります。

Todoデータモデルクラス(@freezed使用),AsyncNotifier状態管理クラス・AsyncNotifierProviderプロバイダ(@riverpod使用):

@freezed
class Todo with _$Todo {
  factory Todo({
    required String id,
    required String description,
    required bool completed,
  }) = _Todo;

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

//これにより、AsyncNotifier と AsyncNotifierProvider が生成されます。
//このクラスは、その「state」プロパティの外部に状態を公開すべきではありません。
//つまり、パブリックゲッター/プロパティはありません!
//このクラスのパブリック メソッドを使用すると、UI が状態を変更できるようになります。
//最後に、todosProvider(AsyncNotifierProvider) を使用して、
//Todos クラスに応じて、UI が状態を変更します。
@riverpod
class AsyncTodos extends _$AsyncTodos {
  Future<List<Todo>> _fetchTodo() async {
    final json = await http.get('api/todos');
    final todos = jsonDecode(json) as List<Map<String, dynamic>>;
    return todos.map(Todo.fromJson).toList();
  }

  @override
  FutureOr<List<Todo>> build() async {
    return _fetchTodo();
  }

  Future<void> addTodo(Todo todo) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      await http.post('api/todos', todo.toJson());
      return _fetchTodo();
    });
  }

  Future<void> removeTodo(String todoId) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      await http.delete('api/todos/$todoId');
      return _fetchTodo();
    });
  }

  Future<void> toggle(String todoId) async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      await http.patch(
        'api/todos/$todoId',
        <String, dynamic>{'completed': true},
      );
      return _fetchTodo();
    });
  }
}

AsyncNotifierProvider を定義したので、それを使用して UI の Todo リストを操作できます。

(AsyncNotifierProvider を使用して) UI の ToDo リストを操作できます。:

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {

    final asyncTodos = ref.watch(asyncTodosProvider);

    return switch (asyncTodos) {
      AsyncData(:final value) => ListView(
          children: [
            for (final todo in value)
              CheckboxListTile(
                value: todo.completed,
                onChanged: (value) {
                  ref.read(asyncTodosProvider.notifier).toggle(todo.id);
                },
                title: Text(todo.description),
              ),
          ],
        ),
      AsyncError(:final error) => Text('Error: $error'),
      _ => const Center(child: CircularProgressIndicator()),
    };
  }
}

StateNotifierProvider

StateNotifierProvider StateNotifier(Riverpod が依存する state_notifier パッケージのクラス)を監視し、公開するためのプロバイダです。 この StateNotifierProvider および StateNotifier は、ユーザ操作などにより変化するステート(状態)を管理するソリューションとして Riverpod が推奨するものです。

一般的には次のような用途で使われます。

  1. 「イミュータブル(不変)」 なステートを公開するため(イミュータブルではあるが、イベントに応じて変わることがある)。
  2. ステートを変更するためのロジック(いわゆるビジネスロジック)を一つの場所で集中管理して保守性を高めるため。

ここで具体例として、StateNotifierProvider を使って Todo リストを管理します。 StateNotifierProvider を使うことで addTodo などのメソッドを公開し、UI 側から Todo リストの内容を操作できるようにします。

(StateNotifierProvider作成)Todoデータモデルクラス(@immutable付加)、Todo状態管理クラス、Todoプロバイダ:
//Todoデータモデルクラス:
// StateNotifier のステート(状態)はイミュータブル(不変)である必要があります。
// ここでは'@immutable'付加によりクラスが不変であるべき事を示しています。
// ※Freezed のようなパッケージを利用してイミュータブルにしても OK です。
@immutable
class Todo {
  const Todo({
    required this.id, 
    required this.description, 
    required this.completed});

  //イミュータブルなクラスのプロパティはすべて `final` にする必要があります。
  final String id;
  final String description;
  final bool completed;

  //Todo はイミュータブルであり、内容を直接変更できない為コピーを作る必要がある。
  //(オブジェクトの各プロパティの内容をコピーし新たな Todoを返すメソッドです)
  Todo copyWith({String? id, String? description, bool? completed}) {
    return Todo(
      id: id ?? this.id,
      description: description ?? this.description,
      completed: completed ?? this.completed,
    );
  }
}

//Todo状態管理クラス:
// StateNotifierProvider に渡すことになる StateNotifier クラスです。
// このクラスでは状態(state)を外に公開しません。
// つまり状態に関しては publicなゲッターやプロパティは作らないという事です。
// public メソッドを通じて UI 側にステートの操作を許可します。
class TodosNotifier extends StateNotifier<List<Todo>> {
  //Todo リストを空のリストとして初期化します。
  TodosNotifier(): super([]);

  //Todo の追加
  void addTodo(Todo todo) {
    //ステート自体もイミュータブルなため、`state.add(todo)`
    //のような操作はできません。
    //代わりに、既存 Todo と新規 Todo を含む新しいリストを作成します。
    //Dart のスプレッド演算子を使うと便利ですよ!
    state = [...state, todo];
    //`notifyListeners` などのメソッドを呼ぶ必要はありません。
    //`state =` により必要な時に UI側に通知が届き、ウィジェットが更新されます。
  }

  //Todo の削除
  void removeTodo(String todoId) {
    //しつこいですが、ステートはイミュータブルです。 
    //そのため既存リストを変更するのではなく、新しくリストを作成する必要がある。
    state = [
      for (final todo in state)
        if (todo.id != todoId) todo,
    ];
  }

  //Todo の完了ステータスの変更
  void toggle(String todoId) {
    state = [
      for (final todo in state)
        //ID がマッチした Todo のみ、完了ステータスを変更します。
        if (todo.id == todoId)
          //またまたしつこいですが、ステートはイミュータブルなので
          //Todo クラスに実装した `copyWith` メソッドを使用して
          //Todo オブジェクトのコピーを作る必要があります。
          todo.copyWith(completed: !todo.completed)
        else
          //ID が一致しない Todo は変更しません。
          todo,
    ];
  }
}

//最後に TodosNotifier のインスタンスを値に持つ StateNotifierProvider
//を作成し、UI 側から Todo リストを操作することを可能にします。
final todosProvider = StateNotifierProvider<TodosNotifier, List<Todo>>((ref) {
  return TodosNotifier();
});

これで StateNotifierProvider の定義は完了です。 次にウィジェット内でプロバイダを利用し、Todo リストの内容表示と操作の部分を実装します。

Dart

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    //Todo リストの内容に変化があるとウィジェットが更新される
    List<Todo> todos = ref.watch(todosProvider);

    //スクロール可能なリストビューで Todo リストの内容を表示
    return ListView(
      children: [
        for (final todo in todos)
          CheckboxListTile(
            value: todo.completed,
            //各Todoをタップすると、完了ステータスを変更できる
            onChanged: (value) => ref.read(todosProvider.notifier).toggle(todo.id),
            title: Text(todo.description),
          ),
      ],
    );
  }
}

補足)
この文献で取り上げられている内容は、StateNotifierProviderとその使用法について説明しており、特に不変のステートを効果的に管理する方法に焦点を当てています。質問に対する回答は以下の通りです。

① @immutableアノテーションの使用目的
@immutableアノテーションは、クラスが不変であるべきことを示します。これはDartのmetaパッケージから提供されるアノテーションで、クラスのすべてのフィールドがfinalであることを強制します。不変クラスは、一度インスタンスが作成されるとその状態が変更されないクラスを指します。このアプローチは、特にフラッターのようなリアクティブプログラミングモデルにおいて、予測可能でバグが少ない状態管理を可能にします。@immutableを使用することで、クラスが意図的に不変であることを他の開発者に明示し、コンパイラによるチェックを通じて安全性を確保します。

② copyWith()メソッドと@freezedアノテーションの関係
copyWith()メソッドは、不変オブジェクトのプロパティを一部変更した新しいオブジェクトを生成するために使用されます。このメソッドは、不変オブジェクトのフィールドの一部だけを変更して新しいインスタンスを生成する際に非常に便利です。手動でcopyWith()メソッドを実装することもできますが、@freezedアノテーションを使用すると、このようなボイラープレートコードを自動的に生成してくれます。@freezedを使えば、不変クラスを簡単に作成し、copyWith()メソッドを自動的に得ることができ、エラーの可能性を減らしながら開発効率を高めることができます。

総合的な説明
StateNotifierProviderの使用例として示されているTodoクラスとTodosNotifierクラスは、不変のステート管理を示す典型的な例です。@immutableアノテーションと手動でのcopyWith()メソッドの実装は、不変性を保ちつつ状態の更新が必要な場合に新しいインスタンスを生成する構造を提供します。@freezedアノテーションを使用すると、これらの機能を自動的に提供することができ、より効率的でエラーの少ないコードが書けるため、大規模なプロジェクトや複雑な状態管理が必要な場合に特に有用です。

FutureProvider

FutureProvider は非同期操作が可能な Provider であると言えます。

一般的には次のような用途で使われます。

  1. 非同期操作を実行し、その結果をキャッシュするため(例えばネットワークリクエストなど)。
  2. 非同期操作の error/loading ステート(state)を適切に処理するため。
  3. 非同期的に取得した、複数の値を組み合わせて一つの値にするため。

FutureProvider ref.watch と組み合わせることでその効果を発揮します。 例えば、何かの値が変わったときに自動でデータを再取得するよう設定することで、プロバイダが常に最新データを外部に公開することを保証できます。

備考)
FutureProvider には値を計算する処理を直接変更する手段がありません(ユーザ操作時)。 値を計算するメソッドが複数必要など、より高度なことをする場合は StateNotifierProvider の使用を検討してください。

使用例: 設定ファイルを取得する

FutureProvider は例えば、次のような用途に適しています:
 JSON ファイルを外部から読み込み、そのデータをもとに Configuration オブジェクトを生成、プロバイダの値として外部へ公開する。

ファイルの読み込みはプロバイダ内で async/await を使うことで可能です。 Flutter のアセットからファイルを読み込む場合は、次のようになります。

Flutterのriverpodライブラリを使って、非同期にローカルのJSONファイルから設定情報を読み込む関数を定義しています。

@riverpod
Future<Configuration> fetchConfiguration(FetchConfigurationRef ref) async {
  final content = json.decode(
    await rootBundle.loadString('assets/configurations.json'),
  ) as Map<String, Object?>;

  return Configuration.fromJson(content);
}

//この関数は特に、アプリの設定や環境に依存するデータを管理する際に有用で、アプリ起動時や必要に応じて設定データを読み込む際に使用されます。
//
//@riverpod アノテーション: このアノテーションを使用することで、関数fetchConfigurationがプロバイダとして動作するようになります。これにより、アプリケーションのどこからでもこの関数を通じて設定データを取得できるようになります。

//1.非同期処理: 
//await rootBundle.loadString('assets/configurations.json')は、アプリのアセットからconfigurations.jsonファイルを非同期に読み込む処理です。awaitキーワードを使用することで、ファイル読み込みの完了を待ってから次の処理に進みます。

//2.JSON データのデコード: 
//json.decode関数は、JSON形式の文字列をDartのマップ(Map<String, Object?>)に変換します。この変換を行うことで、JSONファイルの内容をプログラム内で扱いやすい形にしています。

//3.データの型変換と返却: 
//Configuration.fromJson(content)は、デコードされたマップデータをConfigurationクラスのインスタンスに変換します。このインスタンスは、設定データとしてアプリケーション内で使用され、最終的にこの関数から返されます。

UI 側では以下の通り Configuration を監視します。

このコードは、Flutterのウィジェット内でfetchConfigurationProviderから取得した設定データを表示する方法を示しています。ここでは、非同期データの処理結果に基づいて異なるウィジェットを表示する例です。

Widget build(BuildContext context, WidgetRef ref) {
  final config = ref.watch(fetchConfigurationProvider);
  //configの状態に応じて異なるウィジェットを表示します。
  return switch (config) {
    AsyncError(:final error) => Text('Error: $error'),
    AsyncData(:final value) => Text(value.host),
    //データの取得中はローディングインジケータを表示します。
    _ => const CircularProgressIndicator(),
  };
}

//1.Widget build(BuildContext context, WidgetRef ref):
//Flutterのウィジェットが画面に描画する内容を定義するための標準的なメソッドです。contextは現在のビルドコンテキストを、refはProviderからデータを取得するための参照(WidgetRef)を提供します。
//2.final config = ref.watch(fetchConfigurationProvider):
//ref.watch()を使用しfetchConfigurationProviderから非同期データを監視する。このプロバイダが更新されると、config変数が新しい値で更新され、ウィジェットが再構築されます。
//3.switch (config):
//Dartのswitch文を使用して、configの状態(AsyncError, AsyncData, その他)に基づいて異なるウィジェットを表示します。これにより、データの読み込み中、エラー発生時、データ取得後の3つの異なる状態をユーザーに視覚的に示すことができます。
//4.AsyncError, AsyncData, _:
//AsyncErrorとAsyncDataは、fetchConfigurationProviderから返されるAsyncValue型の値を扱うためのパターンです。それぞれエラー発生時とデータ取得完了時の処理を定義しています。_(アンダースコア)はそれ以外のすべての状態(主に読み込み中)をカバーします。
//全体:
//このコードスニペットは、非同期データの取り扱いとUIの更新方法を非常に効果的に示しており、RiverpodとFlutterを使用したモダンなアプリケーション開発における一般的なパターンです。

これにより Future が解決すると UI は自動で更新されます。 おまけにキャッシュ機能が働くため、複数のウィジェットがこの Configuration を必要とする場合でもアセットは一度しか読み込まれません。

そして、ご覧いただける通り FutureProvider を監視した際の戻り値は AsyncValue です。 AsyncValue のおかげで error/loading などのステートを適切にウィジェットに変換することができます。

StreamProvider

StreamProvider は FutureProvider に似ていますが、Future ではなく Stream を対象としています。

一般的には次のような用途で使われます。

  1. Firebase WebSocket の監視するため。
  2. 一定時間ごと別のプロバイダを更新するため。

Stream 自体が値の更新を監視する性質を持つため、StreamProvider を使うことにあまり意義を見出せない方もいるかもしれません。 Flutter の StreamBuilder で十分ではないか、と。

しかし、StreamBuilder の代わりに StreamProvider を使うことで次の利点を得ることができます。

  1. 他のプロバイダで ref.watch を通じてストリームを監視することができる。
  2. AsyncValue により loading/error のステートを適切に処理することができる。
  3. 通常のストリームとブロードキャスト(broadcast)ストリームを区別する必要がない。
  4. ストリームから出力された直近の値をキャッシュしてくれる途中で監視を開始しても最新の値を取得することができる)。
  5. StreamProvider をオーバーライドすることでテスト時のストリームを簡単にモックすることができる。

使用例: ソケットを使ったライブチャット

StreamProviderは、ビデオストリーミング天気予報のAPIなど非同期データのストリームを扱うときに使用されます。

Dart
final chatProvider = StreamProvider<List<String>>((ref) async* {
  // Connect to an API using sockets, and decode the output
  final socket = await Socket.connect('my-api', 4242);
  ref.onDispose(socket.close);
  
  var allMessages = const <String>[];
  await for (final message in socket.map(utf8.decode)) {
    // A new message has been received. Let's add it to the list of all messages.
    allMessages = [...allMessages, message];
    yield allMessages;
  }
});

そして、UIはこのようにライブストリーミングチャットを監視することができます:

Dart
Widget build(BuildContext context, WidgetRef ref) {
  final liveChats = ref.watch(chatProvider);

  // Like FutureProvider, it is possible to handle loading/error states using AsyncValue.when
  return switch (liveChats) {
    // Display all the messages in a scrollable list view.
    AsyncData(:final value) => ListView.builder(
        // Show messages from bottom to top
        reverse: true,
        itemCount: value.length,
        itemBuilder: (context, index) {
          final message = value[index];
          return Text(message);
        },
      ),
    AsyncError(:final error) => Text(error.toString()),
    _ => const CircularProgressIndicator(),
  };
}

StateProvider

StateProvider は、状態を変更する方法を公開するプロバイダーです。これは NotifierProvider を簡略化したもので、非常に単純な使用例で Notifier クラスを作成する必要がないように設計されています。

StateProvider は主に、ユーザー インターフェイスによる単純な変数の変更を可能にするために存在します。 StateProvider の状態は通常、次のいずれかになります。

  1. 列挙型(enum)、例えばフィルタの種類など
  2. 文字列型、例えばテキストフィールドの入力内容など
  3. bool 型、例えばチェックボックスの値など
  4. 数値型、例えばページネーションのページ数やフォームの年齢など

逆に言えば、StateProvider は次のようなステートを公開するために使うべきではありません。

  • ステートの算出に何かしらのバリデーション(検証)ロジックが必要
  • ステート自体が複雑なオブジェクトである(カスタムのクラスや List/Map など)
  • ステートを変更するためのロジックが単純な count++ よりは高度である必要がある

上記のように一歩踏み込んだステート管理が必要な場合は、カスタムの StateNotifier クラスを定義した上で StateNotifierProvider を利用することをおすすめします。 この場合は最初に多少のボイラープレートコードの設定が必要になりますが、長期的な観点からプロジェクトの保守性を考えれば、 ステートのビジネスロジックを一箇所で集中管理できるため得策だと言えます。

使用例: ドロップダウンメニューを使ってフィルタの種類を切り替える

StateProvider の代表的なユースケースとしては、ドロップダウンメニューやテキストフィールド、 チェックボックスなどフォームに使用されるコンポーネントのステート管理が挙げられます。 ここでは商品リストのソートの種類を切り替えられるドロップダウンメニューを、StateProvider を利用して実装していきます。
わかりやすくするために、取得する製品のリストはアプリケーション内で直接構築され、次のようになります。

Productプロバイダ:
//Productデータモデルクラス:
class Product {
  Product({required this.name, required this.price});

  final String name;
  final double price;
}
//Product初期化:
final _products = [
  Product(name: 'iPhone', price: 999),
  Product(name: 'cookie', price: 2),
  Product(name: 'ps5', price: 500),
];
//Prodectプロバイダ:
final productsProvider = Provider<List<Product>>((ref) {
  return _products;
});

実際のアプリケーションでは、このリストは通常​​、FutureProvider を使用してネットワーク リクエストを作成して取得されます。
ユーザー インターフェイスは次のようにして製品のリストを表示できます。

(UI)商品表示:
//UI:
Widget build(BuildContext context, WidgetRef ref) {
  final products = ref.watch(productsProvider);
  return Scaffold(
    body: ListView.builder(
      itemCount: products.length,
      itemBuilder: (context, index) {
        final product = products[index];
        return ListTile(
          title: Text(product.name),
          subtitle: Text('${product.price} \$'),
        );
      },
    ),
  );
}

これでベースが完成したので、ドロップダウンを追加して、価格または名前で製品をフィルタリングできるようにします。
そのために、 DropDownButton を使います。

Dart

//フィルタの種類を表す列挙型
enum ProductSortType {
  name,
  price,
}

Widget build(BuildContext context, WidgetRef ref) {
  final products = ref.watch(productsProvider);
  return Scaffold(
    appBar: AppBar(
      title: const Text('Products'),
      actions: [
        DropdownButton<ProductSortType>(
          value: ProductSortType.price,
          onChanged: (value) {},
          items: const [
            DropdownMenuItem(
              value: ProductSortType.name,
              child: Icon(Icons.sort_by_alpha),
            ),
            DropdownMenuItem(
              value: ProductSortType.price,
              child: Icon(Icons.sort),
            ),
          ],
        ),
      ],
    ),
    body: ListView.builder(
      // ... /* SKIP */
      itemBuilder: (c, i) => Container(), /* SKIP END */
    ),
  );
}

ドロップダウンができたので、StateProvider を作成し、ドロップダウンの状態をプロバイダーと同期させましょう。
まず、StateProvider を作成しましょう。

Dart

final productSortTypeProvider = StateProvider<ProductSortType>(
  // ソートの種類 name を返します。これがデフォルトのステートとなります。
  (ref) => ProductSortType.name,
);

次に、次のようにして、このプロバイダーをドロップダウンに接続できます。

Dart
DropdownButton<ProductSortType>(
  // ソートの種類が変わると、ドロップダウンメニューが更新されて
  // 表示されるアイコン(メニューアイテム)が変わります。
  value: ref.watch(productSortTypeProvider),
  // ユーザがドロップダウンメニューを操作するとプロバイダのステートが更新されます。
  onChanged: (value) =>
      ref.read(productSortTypeProvider.notifier).state = value!,
  items: [
    // ...
  ],
),

これで、ソートの種類を変更することができるようになりました。 しかし、商品リストにはまだ影響はありません! いよいよ最後の部分です: 商品リストをソートするためにproductsProviderを更新することです。
これを実装する重要な要素は、ref.watchを使用して、productsProviderにソート・タイプを取得させ、ソート・タイプが変更されるたびに商品リストを再計算することです。
実装は次のようになります:

Dart

final productsProvider = Provider<List<Product>>((ref) {
  final sortType = ref.watch(productSortTypeProvider);
  switch (sortType) {
    case ProductSortType.name:
      return _products.sorted((a, b) => a.name.compareTo(b.name));
    case ProductSortType.price:
      return _products.sorted((a, b) => a.price.compareTo(b.price));
  }
});

これだけです! この変更により UI はソートの種類が変わったことを検知して、自動的に商品リストを更新してくれます。

参考) 上記の実装記録

(main.dart)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'product_list_view.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Product Sort Demo',
      home: ProductListView(),
    );
  }
}
(product.dart)
import 'package:flutter_riverpod/flutter_riverpod.dart';

class Product {
  final String name;
  final double price;

  Product({required this.name, required this.price});
}

final productsProvider = Provider<List<Product>>((ref) {
  final sortType = ref.watch(productSortTypeProvider);
  List<Product> products = [
    Product(name: 'iPhone', price: 999),
    Product(name: 'cookie', price: 2),
    Product(name: 'ps5', price: 500),
  ];

  switch (sortType) {
    case ProductSortType.name:
      products.sort((a, b) => a.name.compareTo(b.name));
      break;
    case ProductSortType.price:
      products.sort((a, b) => a.price.compareTo(b.price));
      break;
  }
  return products;
});

enum ProductSortType { name, price }

final productSortTypeProvider = StateProvider<ProductSortType>(
  (ref) => ProductSortType.name,
);
(purodect_list_view.dart)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'product.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    List<Product> products = ref.watch(productsProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Products'),
        actions: [
          DropdownButton<ProductSortType>(
            value: ref.watch(productSortTypeProvider),
            onChanged: (value) {
              if (value != null) {
                ref.read(productSortTypeProvider.notifier).state = value;
              }
            },
            items: const [
              DropdownMenuItem(
                value: ProductSortType.name,
                child: Icon(Icons.sort_by_alpha),
              ),
              DropdownMenuItem(
                value: ProductSortType.price,
                child: Icon(Icons.sort),
              ),
            ],
          ),
        ],
      ),
      body: ListView.builder(
        itemCount: products.length,
        itemBuilder: (context, index) {
          final product = products[index];
          return ListTile(
            title: Text(product.name),
            subtitle: Text('${product.price} \$'),
          );
        },
      ),
    );
  }
}

プロバイダーを 2 回読み取ることなく、前の値に基づいて状態を更新する方法

場合によっては、以前の値に基づいて StateProvider の状態を更新したいことがあります。
当然、最終的には次のように書くことになるかもしれません。

これだと少し面倒ですよね?:

final counterProvider = StateProvider<int>((ref) => 0);

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // 直近のステートから新たなステートを算出しようとすると、
          // このようにプロバイダを2回利用してしまいがち。
          ref.read(counterProvider.notifier).state = ref.read(counterProvider.notifier).state + 1;
        },
      ),
    );
  }
}

このスニペットには特に問題はないが、構文が少し不便である。 構文を少し良くするために、update関数を使うことができる。 この関数は、現在の状態を受け取るコールバックを受け取り、新しい状態を返すことが期待されます。 この関数を使って、以前のコードをリファクタリングすることができます:

Dart

final counterProvider = StateProvider<int>((ref) => 0);

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          ref.read(counterProvider.notifier).update((state) => state + 1);
        },
      ),
    );
  }
}

この変更により、構文が少し改善されながらも同じ効果が得られます。

ChangeNotifierProvider

ChangeNotifierProvider (flutter_riverpod/hooks_riverpod のみ)は ChangeNotifier を Flutter で利用するためのプロバイダです。

Riverpod では使用を非推奨としており、主に次の理由により存在しています。

package:provider で ChangeNotifierProvider を利用していた場合の移行作業を容易にするため
ミュータブル(可変)なステート管理手法をサポートするため

備考)
可能な限り StateNotifierProvider を使用してください。
ChangeNotifierProvider の使用はミュータブルなステート管理を行う必然性がある場合に限定してください。

ミュータブル(可変)なステートを管理する方が都合がいいと感じるケースもあるかもしれません。 しかし、それによりコードの保守性を損ない、実装した機能が想定外の動作をするおそれがあることも念頭に置いてください。 例えば、ウィジェットの再構築を最適化するために provider.select を使っている場合、 ステートの変化がうまく select に伝わらず、再構築が機能しない可能性があります。 結果的にイミュータブルなステートを管理していた方が確実で効率が良かったと振り返ることもあるかもしれません。 そのため、ChangeNotifierProvider の導入を検討する際はユースケースに特化したベンチマークを行い、導入により全体的なパフォーマンスにどう影響するのかを慎重に見極めることが重要です。

以下のコードは Todo リストでの使用例です。まずは Todo のモデルと ChangeNotifier を定義し、ChangeNotifierProvider を作成します。

Dart

class Todo {
  Todo({
    required this.id,
    required this.description,
    required this.completed,
  });

  String id;
  String description;
  bool completed;
}

class TodosNotifier extends ChangeNotifier {
  final todos = <Todo>[];

  // UI 側から Todo アイテムを追加できるようにする
  void addTodo(Todo todo) {
    todos.add(todo);
    notifyListeners();
  }

  // Todo アイテムの削除
  void removeTodo(String todoId) {
    todos.remove(todos.firstWhere((element) => element.id == todoId));
    notifyListeners();
  }

  // Todo の完了ステータスの変更
  void toggle(String todoId) {
    for (final todo in todos) {
      if (todo.id == todoId) {
        todo.completed = !todo.completed;
        notifyListeners();
      }
    }
  }
}

// 最後に ChangeNotifierProvider を通じて UI 側から
// TodosNotifier を監視・操作できるようにする
final todosProvider = ChangeNotifierProvider<TodosNotifier>((ref) {
  return TodosNotifier();
});

次に ChangeNotifierProvider を通じて、UI 側から Todo リストを監視・操作できるようにします。

Dart

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Todo リストの内容に変更があればウィジェットを再構築
    List<Todo> todos = ref.watch(todosProvider).todos;

    // スクロール可能な ListView で Todo リストを表示
    return ListView(
      children: [
        for (final todo in todos)
          CheckboxListTile(
            value: todo.completed,
            // Todo をタップして完了ステータスを変更
            onChanged: (value) =>
                ref.read(todosProvider.notifier).toggle(todo.id),
            title: Text(todo.description),
          ),
      ],
    );
  }
}

(次の記事はこちら)

コメントを残す