メインコンテンツに進む

副作用の実行

これまで、データの取得方法(いわゆるGET リクエストの実行)についてのみ見てきました。
しかし、POST リクエストのような副作用はどうでしょうか?

アプリケーションはしばしば CRUD(作成、読み取り、更新、削除)API を実装します。
その際、更新リクエスト(通常はPOST)はローカルキャッシュも更新して UI に新しい状態を反映させることが一般的です。

問題は、Consumer の中から provider の状態をどう更新するかです。
当然、provider は状態を変更する方法を公開していません。
これは、状態が制御された方法でのみ変更されるようにし、関心の分離を促進するための設計です。
代わりに、provider は状態を変更する方法を明示的に公開する必要があります。

そのために、新しい概念を使用します: Notifiers.
この新しい概念を紹介するために、もう少し進んだ例を使用します:ToDo リスト

Notifier の定義

ここで、これまでに知っていることから始めましょう:シンプルなGET リクエストです。 最初の provider/ネットワークリクエストを作成するで見たように、次のように書いて ToDo リストを取得できます:


Future<List<Todo>> todoList(Ref ref) async {
// ネットワーク要求をシミュレートします。これは通常、実際のAPIから来るものです。
return [
Todo(description: 'Learn Flutter', completed: true),
Todo(description: 'Learn Riverpod'),
];
}

今度は、取得した ToDo リストに新しい ToDo を追加する方法を見てみましょう。 これには、provider が状態を変更するための公開 API を提供するように変更する必要があります。
これを行うために、provider を"notifier"に変換します。

Notifiers は Provider の"stateful widget"です。
provider を定義するための構文に少し修正が必要です。
この新しい構文は次のとおりです:

@riverpod
class MyNotifier extends _$MyNotifier {
  @override
  Result build() {
    <your logic here>
  }
  <your methods here>
}
アノテーション

すべての Provider は@riverpodまたは@Riverpod()でアノテーションする必要があります。
このアノテーションはグローバル関数またはクラスに配置できます。
このアノテーションを通して、provider を設定できます。

例えば、 @Riverpod(keepAlive: true)と書くことで"auto-dispose"(後で説明)を無効にすることができます。

Notifier

クラスに@riverpod アノテーションが付けられるとそのクラスは "Notifier"と呼ばれます。
そのクラスは _$NotifierNameを拡張する必要があり、 NotifierNameはクラス名です。

Notifiers は、provider の状態を変更する方法を公開する責任を負います。
このクラスのパブリックメソッドには、ref.read(yourProvider.notifier).yourMethod()を使用して consumers がアクセスできます。

注記

UI で状態が変更されたことを知る手段がないため、Notifiers には組み込みの state 以外の公開プロパティがあってはなりません。

buildメソッド

全ての notifiers は build メソッドをオーバーライドする必要があります。
このメソッドは、通常、non-notifier provider でロジックを入れる場所に相当します。

このメソッドは直接呼び出してはいけません。

参考までに、この新しい構文を以前見た構文と比較するために最初の provider/ネットワークリクエストを作成する を確認すると良いでしょう。

備考

build以外のメソッドがない Notifier は、以前見た構文を使用するのと同じです。

最初の provider/ネットワークリクエストを作成する に示された構文は、UI から変更する方法がない notifiers に対する省略形と考えることができます。

構文を見たところで、以前定義した Provider を Notifier に変換する方法を見てみましょう:


class TodoList extends _$TodoList {

Future<List<Todo>> build() async {
// 以前FutureProviderに記述していたロジックがbuildメソッドにあります。
return [
Todo(description: 'Learn Flutter', completed: true),
Todo(description: 'Learn Riverpod'),
];
}
}

ウィジェット内で provider を読み取る方法は変更されていません。
以前の構文と同様に ref.watch(todoListProvider) を使用できます。

注意

notifier のコンストラクタにロジックを入れないでください。
ref およびその他のプロパティはその時点ではまだ使用できないため、Notifiers にはコンストラクタがないはずです。 代わりに、ロジックをbuildメソッドに入れてください。

class MyNotifier extends ... {
MyNotifier() {
// ❌ これはしないでください。
// 例外を投げます。
state = AsyncValue.data(42);
}


Result build() {
// ✅ 代わりにここにロジックを入れてください。
state = AsyncValue.data(42);
}
}

POSTリクエストを実行するメソッドの公開

Notifier ができたので、今度は副作用を実行するメソッドを追加できます。
その副作用の一つとして、新しい ToDo をPOSTするクライアントを作成することがあります。
notifier にaddTodoメソッドを追加することでそれが可能になります:


class TodoList extends _$TodoList {

Future<List<Todo>> build() async => [/* ... */];

Future<void> addTodo(Todo todo) async {
await http.post(
Uri.https('your_api.com', '/todos'),
// We serialize our Todo object and POST it to the server.
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);
}
}

次に、最初の provider/ネットワークリクエストを作成する で見たのと同じConsumer/ConsumerWidgetを使用して UI でこのメソッドを呼び出せます:

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


Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
onPressed: () {
// Using "ref.read" combined with "myProvider.notifier", we can
// obtain the class instance of our notifier. This enables us
// to call the "addTodo" method.
ref
.read(todoListProvider.notifier)
.addTodo(Todo(description: 'This is a new todo'));
},
child: const Text('Add todo'),
);
}
}
備考

メソッドを呼び出す際にref.watch の代わりに ref.readを使用していることに注意してください。
技術的にはref.watchでも動作しますが、
"onPressed"のようなイベントハンドラーでロジックを実行する場合は、ref.readを使用することをお勧めします。

これで、ボタンを押すとPOSTリクエストを行うボタンができました。
しかし、現時点では、UI が新しい ToDo リストを反映して更新されることはありません。
ローカルキャッシュをサーバーの状態と一致させたいと思います。

これにはいくつかの方法があり、それぞれに利点と欠点があります。

API レスポンスに合わせてローカルキャッシュを更新する

一般的なバックエンドの慣行は、POSTリクエストがリソースの新しい状態を返すようにすることです。
特に、API は新しい Todo を追加したリストを返します。 これを行う方法の一つはstate = AsyncData(response)と記述することです:

  Future<void> addTodo(Todo todo) async {
// POSTリクエストは、新しいアプリケーションの状態と一致するList<Todo>を返します。
final response = await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// API応答をデコードしてList<Todo>に変換します。
List<Todo> newTodos = (jsonDecode(response.body) as List)
.cast<Map<String, Object?>>()
.map(Todo.fromJson)
.toList();

// 新しい状態と一致するようにローカルキャッシュを更新します。
// これにより、すべてのリスナーに通知が送信されます。
state = AsyncData(newTodos);
}
利点
  • UI は可能な限り最新の状態に保たれます。
    他のユーザーが ToDo を追加すると、私たちもそれを見ることができます。
  • サーバーが真実の源です。
    このアプローチを使用すると、クライアントは ToDo リストに新しい ToDo をどこに挿入するかを知る必要がありません。
  • ネットワークリクエストは一度だけ必要です。
欠点
  • このアプローチは、サーバーが特定の方法で実装されている場合にのみ機能します。
    サーバーが新しい状態を返さない場合、このアプローチは機能しません。
  • フィルタリング/ソートなどが含まれる場合、またはより複雑なGETリクエストの場合、このアプローチは機能しない可能性があります。

ref.invalidateSelf()を使用して provider を更新する

もう一つの方法は、provider がGETリクエストを再実行するようにすることです。
これは、POSTリクエスト後にref.invalidateSelf()を呼び出すことで行います:

  Future<void> addTodo(Todo todo) async {
// APIのレスポンスは気にしません。
await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// Postリクエストが完了すると、ローカルキャッシュをダーティー(Dirty)と表現することができます。
// そうすると、notifierの"build"が非同期で再度呼び出されます、
// この時、リスナーに通知が送信されます。
ref.invalidateSelf();

// (オプション)その後、新しいステータスが計算されるまで待つことができます。
// これにより、新しい状態が利用可能になるまで、"addTodo "は完了しない。
await future;
}
利点
  • UI は可能な限り最新の状態に保たれます。
    他のユーザーが ToDo を追加すると、私たちもそれを見ることができます。
  • サーバーが真実の源です。
    このアプローチを使用すると、クライアントは ToDo リストに新しい ToDo をどこに挿入するかを知る必要がありません。
  • このアプローチは、サーバーの実装に関係なく機能します。
    フィルタリング/ソートなどが含まれる場合、またはより複雑なGETリクエストの場合、特に有用です。
欠点
  • このアプローチは追加のGETリクエストを実行するため、効率が悪い可能性があります。

ローカルキャッシュを手動で更新する

別の方法は、ローカルキャッシュを手動で更新することです。
これには、バックエンドの動作を模倣する作業が含まれます。
たとえば、バックエンドが新しい項目を先頭に挿入するのか末尾に挿入するのかを知る必要があります。

  Future<void> addTodo(Todo todo) async {
// APIのレスポンスは重要ではありません。
await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// その後、ローカルキャッシュを手動で更新することができます。
// そのためには、以前の状態を取得する必要があります。
// 注意: 前の状態がまだロード中またはエラー状態である可能性があります。
// これを処理するエレガントな方法は、`this.state`ではなく、`this.state`の代わりに
// `this.future`を読み込んで読み込み状態を待たせたり
// ステータスがエラー状態の場合、エラーをスローします。
final previousState = await future;

// その後、新しい状態オブジェクトを作成して状態を更新することができます。
// すると、すべてのリスナーに通知が送信されます。
state = AsyncData([...previousState, todo]);
}
備考

この例では immutable state を使用しています。
必要ではありませんが、immutable state を使用することをお勧めします。
詳細はwhy_immutabilityを参照してください。
代わりに、mutable state を使用する場合は、別の方法を使用できます:

    final previousState = await future;
// 以前のToDoリストを変更します。
previousState.add(todo);
// リスナーに手動で通知を送信します。
ref.notifyListeners();
利点
  • このアプローチはサーバーの実装に関係なく機能します。
  • ネットワークリクエストは 1 回だけ必要です。
欠点
  • ローカルキャッシュはサーバーの状態と一致しない可能性があります。
    もし他のユーザーが ToDo を追加した場合、私たちはそれを見ることができません。
  • このアプローチはバックエンドのロジックを効果的に複製し、実装がより複雑になる可能性があります。

さらに進む:スピナーの表示とエラーハンドリング

これまで見てきたように、ボタンを押すとPOSTリクエストを行い、リクエストが完了すると UI が更新されて変更を反映します。
しかし、現時点では、リクエストが実行されていることを示すものや、失敗した場合の情報はありません。

一つの方法は、addTodo が返す Future をローカルウィジェットの状態に保存し、その Future をリッスンしてスピナーやエラーメッセージを表示することです。
このシナリオでは flutter_hooksが役立ちますが、
もちろん StatefulWidget を代わりに使用することもできます。

次のスニペットは、操作が保留中の間に進行状況インジケーターを表示し、 失敗した場合にボタンを赤く表示します:

A button which turns red when the operation failed

class Example extends ConsumerStatefulWidget {
const Example({super.key});


ConsumerState<ConsumerStatefulWidget> createState() => _ExampleState();
}

class _ExampleState extends ConsumerState<Example> {
// 保留中のaddTodo操作。保留中の操作がない場合は null。
Future<void>? _pendingAddTodo;


Widget build(BuildContext context) {
return FutureBuilder(
// 保留中の操作をリッスンし、それに応じてUIを更新する。
future: _pendingAddTodo,
builder: (context, snapshot) {
// エラー状態があるかどうかを計算します。
// connectionStateチェックは、操作が再試行されたときに処理するため。
final isErrored = snapshot.hasError && snapshot.connectionState != ConnectionState.waiting;

return Row(
children: [
ElevatedButton(
style: ButtonStyle(
// エラーがある場合、ボタンが赤く表示されます。
backgroundColor: WidgetStatePropertyAll(
isErrored ? Colors.red : null,
),
),
onPressed: () {
// addTodoが返したfutureを変数に保存します。
final future = ref
.read(todoListProvider.notifier)
.addTodo(Todo(description: 'This is a new todo'));

// そのfutureをローカルstateに保存します。
setState(() {
_pendingAddTodo = future;
});
},
child: const Text('Add todo'),
),
// 作業が保留中です。インジケータを表示しています。
if (snapshot.connectionState == ConnectionState.waiting) ...[
const SizedBox(width: 8),
const CircularProgressIndicator(),
]
],
);
},
);
}
}