ミューテーション
ミューテーションは、新しい Todo など、変更をサーバーに送ります。ミューテーションはボタンから実行できます。進行状況はどの画面にも表示でき、失敗したときはユーザーに伝えられます。サーバーが応答する前に、キャッシュを更新することもできます。
次のミューテーションは Todo を追加します。
final addTodo = Mutation( mutationKey: const ['todos', 'add'], mutationFn: (String title) => api.addTodo(title), onSuccess: (todo, title, context, client) { return client.invalidateQueries(queryKey: ['todos']); },);上の String title のように mutationFn のパラメーターに型を付けると、Dart はほかの型をそこから推論します。mutationKey があれば、どのウィジェットでもミューテーションの実行を見つけられます。1 回の mutate 呼び出しが 1 つの実行です(ミューテーションの実行)。すべてのオプションの一覧はミューテーションのオプションにあります。
ミューテーションを実行する
Section titled “ミューテーションを実行する”定義の mutate を呼び出し、context.queryClient を渡してください。この方法なら、StatelessWidget も含め、どのウィジェットでもミューテーションを実行できます。
class AddTodoButton extends StatelessWidget { const AddTodoButton({super.key});
@override Widget build(BuildContext context) { return FilledButton( onPressed: () => addTodo.mutate('Buy milk', context.queryClient), child: const Text('Add'), ); }}mutateは実行を開始し、すぐに戻ります。実行が失敗すると、エラーは呼び出し元には届かず、実行の状態とコールバックに届きます。- 定義は状態を持ちません。実行はウィジェットではなく、クライアントのキャッシュに属します。そのため、ボタンが画面からなくなっても実行は続きます。
context.queryClientは、ウィジェットが使うクライアントです。最も近いFueryProviderのクライアント、FueryProviderがなければFuery.clientです。このクライアントを渡すと、MutationState ウィジェットが読み取るキャッシュに実行が入ります。- クライアントを渡さない場合、実行は
Fuery.clientを使います。Cubit など、BuildContextのないコードは、この方法でミューテーションを実行します。
ミューテーションのすべての実行を表示する
Section titled “ミューテーションのすべての実行を表示する”MutationState ウィジェットは、ミューテーションの実行をどの画面にでも表示します。定義の mutationKey で実行を見つけるので、各実行がどこで開始されたかは問いません。開始元は、定義の mutate、MutationBuilder、useMutation、Cubit、restore(mutations:) のどれでもかまいません。StatelessWidget の中でも動作します。
| ウィジェット | ビルド元、またはリッスンする対象 |
|---|---|
MutationStateBuilder |
すべての実行の MutationState(開始順) |
MutationStateSelector |
それらの状態から選択した値。値が変わったときだけリビルドします。 |
MutationStateListener |
各実行の各変更(副作用のため) |
先ほどのボタンを、Todo の追加中は無効にした例です。
MutationStateSelector( mutation: addTodo, selector: (runs) => runs.any((run) => run.isPending), builder: (context, adding) => FilledButton( onPressed: adding ? null : () => addTodo.mutate('Buy milk', context.queryClient), child: Text(adding ? 'Adding…' : 'Add'), ),)サーバーへ送信中のタイトルを表示する例です。タイトルは、定義の変数と同じく String 型です。
MutationStateBuilder( mutation: addTodo, builder: (context, runs) => Column( children: [ for (final run in runs) if (run case MutationState(isPending: true, :final variables?)) ListTile(title: Text(variables)), ], ),)一致する実行
Section titled “一致する実行”Mutationを渡すと、ウィジェットはmutationKeyが完全に一致する実行を、定義と同じ型で表示します。- 同じキーと型を持つ別の定義の実行も対象になります。
- 同じキーで型が異なる実行は除外され、
onUncaughtErrorに 1 回だけ報告されます。定義ごとに固有のキーを付けてください。 mutationKeyのない定義は、デバッグビルドでアサートに失敗します。MutationFiltersを渡すと、ウィジェットは、フィルターに一致するすべてのミューテーションの実行を表示します。一致の判定はclient.mutationCache.findAllと同じで、キーのプレフィックス、exactによる完全一致、status、predicateで絞り込みます。状態の型はObject?です。statusフィルターがない場合、完了した実行も一致します。
実行の順序と保持期間
Section titled “実行の順序と保持期間”- 実行は開始順に並ぶので、
runs.lastOrNullが最新の実行です。 - 実行は、完了後も
gcTime(デフォルトは 5 分)の間残ります。そのため、インジケーターは実行の数ではなくisPendingからビルドしてください。 - マウントされた
MutationBuilderは、最新の実行を表示している間、その実行を保持します。 client.clear()は、すべての実行を削除します。
クライアントとコスト
Section titled “クライアントとコスト”- 対象になるのは、ウィジェットのクライアントの実行だけです。ウィジェットのクライアントは、最も近い
FueryProviderのクライアント、またはFuery.clientです。 - これらのウィジェットは読み取るだけです。ミューテーションを実行することはなく、定義のオプションも適用しません。そのため、
buildの中で定義を作ってもコストはかかりません。
ミューテーションの失敗をユーザーに伝える
Section titled “ミューテーションの失敗をユーザーに伝える”失敗した mutate は、エラーをスローせずに実行の状態に入れます。MutationStateListener は、どの画面からのものでも、ミューテーションのすべての実行をリッスンし、各実行の新しい状態を受け取ります。
MutationStateListener( mutation: addTodo, listenWhen: (previous, current) => current.isError, listener: (context, run) => ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Could not add "${run.variables}"')), ), child: const TodoScreen(),)- Fuery は、変わった実行ごとにリスナーを 1 回呼び出します。そのため、2 つの実行が失敗すると、2 回呼び出します。
listenWhenは、その実行の前の状態と新しい状態を比較します。- マウントした時点で実行がすでに持っていた状態や、キャッシュが削除した実行については、リスナーは呼び出されません。
- 復元された実行や、ほかの画面からの実行もリッスンします。そのため、メッセージを表示すべき場所に 1 つだけマウントしてください。
MutationListener は、受け取ったオブザーバーの実行だけをリッスンします。定義を渡すと、どこからも実行されない独自のオブザーバーを作るので、何もリッスンしません。デバッグビルドでは、警告も出力します。MutationListener が反応しないを参照してください。
1 回の呼び出しが成功した後に処理する
Section titled “1 回の呼び出しが成功した後に処理する”mutateAsync は、実行が成功するとデータを返し、失敗するとエラーをスローします。保存したフォームを閉じるなど、1 回の呼び出しに対する処理には、mutateAsync を await してください。
FilledButton( onPressed: () async { try { await addTodo.mutateAsync('Buy milk', context.queryClient); } catch (_) { return; // The MutationStateListener above reports the failure. } if (context.mounted) Navigator.pop(context); }, child: const Text('Add'),)awaitの後でcontext.mountedを確認してください。実行が pending の間に、ユーザーが画面を離れることがあります。- エラーはキャッチしてください。どこでもキャッチしないエラーは、キャッチされないエラーとしてゾーンに届きます。
ウィジェットが開始した実行だけを表示する
Section titled “ウィジェットが開始した実行だけを表示する”MutationBuilder は独自のオブザーバーを保持し、自身が開始した実行だけを表示します。複数のフォームそれぞれにある保存ボタンなど、ウィジェットの状態から、ほかの場所で開始された実行を除く必要がある場合に使ってください。
MutationBuilder( mutation: addTodo, builder: (context, state) => FilledButton( onPressed: state.isPending ? null : () => state.mutate('Buy milk'), child: Text(state.isPending ? 'Adding…' : 'Add'), ),)state.mutate('Buy milk')は、このウィジェット経由で実行を開始します。await state.mutateAsync('Buy milk')はデータを返し、エラーのときはスローします。stateは、このウィジェットが開始した最新の実行を表します。addTodo.mutateやほかのウィジェットで開始した実行は、stateに表れません。state.reset()は、状態を idle に戻します。- ウィジェットは、最も近い
FueryProviderのクライアントを使います。FueryProviderがなければFuery.clientを使います。 HookWidgetでは、useMutation(addTodo)が同じ結果を返します。フックを参照してください。
state のすべてのメンバーとフィールドの一覧はミューテーションの結果にあります。
このウィジェットの 1 回の呼び出しに反応するには、state.mutate に MutateOptions を渡してください。MutateOptions のコールバックは、呼び出しが完了した後、ミューテーション自身のコールバックの次に実行されます。
state.mutate( 'Buy milk', MutateOptions(onSuccess: (todo, title, _, __) => showAddedSnackBar(todo)),);- 同じオブザーバーで後から
mutateを呼び出すと、コールバックは置き換わります。実行されるのは、最新の呼び出しのコールバックだけです。 reset()は、コールバックを破棄します。ウィジェットをアンマウントしたときも破棄します。- 共有したオブザーバーは、ウィジェットがリッスンしているかどうかにかかわらず、コールバックを実行します。コールバックで
BuildContextを使う前に、context.mountedを確認してください。
コールバック
Section titled “コールバック”onMutate は mutationFn の前に実行されます。onSuccess、onError、onSettled は mutationFn の後に実行されます。引数と実行順の一覧はコールバックにあります。
- 最後の引数
clientは、ミューテーションを実行しているクライアントです。定義のmutateに渡したクライアント、ウィジェットがFueryProviderから受け取ったクライアント、またはobserve(client:)に渡したクライアントです。Fuery.clientの代わりにこのclientを使ってください。そうすれば、テストでもコールバックが正しいキャッシュに届きます。 onMutate、onSuccess、onError、onSettledが Future を返すと、その Future が完了するまで実行は pending のままです。Fuery はMutateOptionsのコールバックを await しません。上のaddTodoはonSuccessからinvalidateQueriesの Future を返します。そのため、リストの再取得が終わるまで、ボタンは Adding… と表示します。
楽観的更新は、サーバーが応答する前にキャッシュを変更します。そのため、画面がすぐに反応します。
onMutateで、クエリの再取得をキャンセルします。そうすれば、どの再取得も更新を上書きしません。- 同じ
onMutateで新しいデータを書き込み、以前のデータを返します。ほかのコールバックは、以前のデータをcontextとして受け取ります。 onErrorで、以前のデータを書き戻します。onSettledでクエリを無効化し、サーバーにあるデータを再取得します。
todosQuery と todosKey は、クエリとそのキーです。
final deleteTodo = Mutation( mutationFn: (int id) => api.deleteTodo(id), onMutate: (id, client) async { // Keep a refetch in flight from overwriting the optimistic update. await client.cancelQueries(queryKey: todosKey); final previous = client.getData(todosQuery); client.updateData( todosQuery, (todos) => todos?.where((todo) => todo.id != id).toList(), ); return previous; }, onError: (error, id, previous, client) { if (previous != null) client.setData(todosQuery, previous); }, onSettled: (_, __, ___, ____, client) { return client.invalidateQueries(queryKey: todosKey); },);変数のないミューテーション
Section titled “変数のないミューテーション”ログアウトなど、何も受け取らないミューテーションには、NoVariablesMutation を使ってください。mutationFn とコールバックは変数を受け取りません(NoVariablesMutation)。
final logoutMutation = NoVariablesMutation( mutationFn: () => api.logout(),);logoutMutation.mutate() で実行するので、ボタンには tear-off を渡せます(onPressed: logoutMutation.mutate)。logoutMutation.mutateAsync() はデータを返します。
Fuery.client以外のクライアントで実行するときは、最初に変数としてnullを渡してください(logoutMutation.mutate(null, context.queryClient))。MutationBuilderやuseMutationの結果は変数がvoidなので、state.mutate(null)でミューテーションを実行します。1 回の呼び出しのコールバックには変数の引数が残り、その値はnullです。observe()で作ったオブザーバー(NoVariablesMutationObserver)は、mutate()で実行します。
ログアウトした後は、キャッシュを使っていた画面からアプリが離れてから、キャッシュを空にしてください。この順序が重要な理由は、ログアウト時にすべてを消去するで説明しています。
再試行と実行の順序
Section titled “再試行と実行の順序”書き込みを繰り返すのは常に安全とは限りません。そのため、ミューテーションは retry を設定したときだけ再試行します。RetryPolicy.count(2) は、1 秒、2 秒の間隔で、あと 2 回の試行を許可します。同じ scope を共有するミューテーションは、開始した順に 1 つずつ実行されます。
final saveDraft = Mutation( mutationFn: (Draft draft) => api.saveDraft(draft), retry: const RetryPolicy.count(2), scope: const MutationScope('drafts'),);スコープで順番を待っている実行は、isPaused を示します。ネットワークを待っている実行も同じです。待っている実行を再起動の後も残すには、ミューテーションに persist を指定してください(ミューテーションを永続化する)。
1 つのオブザーバーを共有する
Section titled “1 つのオブザーバーを共有する”定義の mutate と MutationState ウィジェットには、独自のオブザーバーは必要ありません。Cubit も、定義からミューテーションを実行します(Cubit や Bloc からのミューテーション)。オブザーバーを共有するのは、複数のウィジェットが 1 つの画面の実行だけを追う必要がある場合に限ってください。
addTodo.observe() は MutationObserver を返します。そのオブザーバーを渡したウィジェットはすべて、オブザーバーをそのまま使います。次の例では、この画面のフォームが保存している間、アプリバーにプログレスバーを表示します。MutationStateSelector を使うと、ほかの画面が開始した実行も表示されます。
class _AddTodoScreenState extends State<AddTodoScreen> { late final adding = addTodo.observe(client: context.queryClient);
@override void dispose() { adding.reset(); super.dispose(); }
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('New todo'), bottom: PreferredSize( preferredSize: const Size.fromHeight(4), child: MutationSelector( mutation: adding, selector: (state) => state.isPending, builder: (context, saving) => saving ? const LinearProgressIndicator() : const SizedBox(height: 4), ), ), ), body: AddTodoForm(onSubmit: adding.mutate), ); }}画面自身の呼び出しが成功した後に画面を閉じるには、共有したオブザーバーは必要ありません。mutateAsync を await してください(1 回の呼び出しが成功した後に処理する)。
- オブザーバーは、
Stateのフィールドか Cubit で 1 回だけ作ってください。buildの中のobserve()は、リビルドのたびに idle の新しいオブザーバーを返します。 observe()は、client:を渡さない限りFuery.clientを使います。独自のクライアントを持つFueryProviderの下では、上の例のようにcontext.queryClientを渡してください。disposeでreset()を呼び出してください。reset()は、最新のmutate呼び出しのコールバックを破棄します。そのコールバックは、この画面に属するものです。定義を渡したウィジェットは、アンマウント時に自身のオブザーバーをリセットします。- オブザーバーを渡した
MutationListener、MutationSelector、MutationBuilderは、そのオブザーバーが開始するすべての実行をリッスンします。どこから呼び出されたかは問いません。
サンプルアプリでは
Section titled “サンプルアプリでは”- フィードのミューテーションには、ロールバック付きの楽観的な「いいね」と、オフラインの間は一時停止するコメントがあります。
- フィードは、各「いいね」を定義から実行し、失敗したすべての「いいね」を
MutationStateListenerで報告します。 - 投稿画面は、定義からコメントを送信し、送信中のコメントを
MutationStateBuilderで一覧表示します。 - 投稿作成画面は、
MutationBuilderから新しい投稿を実行します。そのボタンは、自身の実行だけを表示します。
サンプルアプリの README に、各画面で示している内容の一覧があります。