[TanStack Query v5] useMutation callback と mutate callback の挙動の違い
こんにちは、株式会社カナリーでソフトウェアエンジニアをやっている matsu です。
先日 TanStack Query の mutation 処理で思わぬ落とし穴に遭遇したのですが、ドキュメントや解説記事が見当たらなかったので、本記事にて知見を共有します 🐈⬛
はじめに
TanStack Query の mutation 処理では、onSuccess
/ onError
/ onSettled
オプションにより、mutation 処理後に実行される callback 関数を登録できます。
これらの callback 関数は、React の場合、useMutation
と mutate (mutateAsync)
の両方で指定可能です (Vue や Svelte 等の他の FW でも関数名は異なれど同様です)。
例えば以下のようなイメージ。
import { useMutation } from "@tanstack/react-query";
export const TestComponent = () => {
const { mutate } = useMutation({
mutationFn: () => new Promise((resolve) => resolve("Mutation Completed")),
onSuccess: () => { // useMutation で指定する場合
console.log("useMutation onSuccess");
},
onError: () => {
console.log("useMutation onError");
},
onSettled: () => {
console.log("useMutation onSettled");
},
});
const handleClick = () => {
mutate(undefined, {
onSuccess: () => { // mutate で指定する場合
console.log("mutate onSuccess");
},
onError: () => {
console.log("mutate onError");
},
onSettled: () => {
console.log("mutate onSettled");
},
});
};
return (
<button type="button" onClick={handleClick}>
Click me
</button>
);
};
両者の callback は一見同じように見えますが、実はどちらで指定するかによって実行タイミング等の挙動が異なります。
こうした違いは、意識せずとも問題ない場合が多いかもしれませんが、知っておかないと意図しない挙動を埋め込んでしまうような、注意すべき場面も存在します。
本記事では、実際のコードを追いながら両者の挙動の差異を解説した後に、使い分けが必要な場面をサンプルコードを交えつつ紹介します。
結論
- 実行順序は useMutation callback -> mutate callback です。
- 成功時は、useMutation onSuccess -> useMutation onSettled -> mutate onSuccess -> mutate onSettled となります。
- 失敗時は、useMutation onError -> useMutation onSettled -> mutate onError -> mutate onSettled となります。
- 実行時のステータスが異なっています。useMutation callback が実行される時のステータスは pending であるのに対し、mutate callback が実行される時のステータスは success/error です。
- mutation 処理中にコンポーネントが アンマウント された場合、useMutation callback は実行されるのに対し、mutate callback は実行されないです。
- mutate が複数同時に実行された場合、useMutation callback は各 mutate 処理後に実行されるのに対し、mutate callback は最後に実行された mutate のものだけが実行されます。
実際のコードを追ってみる
それでは、実際のコードを読んで mutation 処理の流れを追ってみます。
コードの中身に関心が無い方は、このセクションは飛ばしていただいても構いません🙏
mutation 処理の大まかな流れ
以下のようなシンプルな mutation 処理を想定して流れを追っていきます。
import { useMutation } from "@tanstack/react-query";
export const TestComponent = () => {
const { mutate } = useMutation({ // ① useMutation の呼び出し
mutationFn: () => new Promise((resolve) => resolve("Mutation Completed")),
onSettled: () => {
console.log("useMutation onSettled");
},
});
const handleClick = () => {
mutate(undefined, { // ② mutate の呼び出し
onSettled: () => {
console.log("mutate onSettled");
},
});
};
return (
<button type="button" onClick={handleClick}>
Click me
</button>
);
};
mutation 処理の流れを追う上で特に重要なファイルは useMutation.ts, mutationObserver.ts, mutation.ts の 3 つです。
① useMutation が呼び出された際に起こること
Step 1. MutationObserver インスタンスが作成される
useMutation
の全体像は以下の通りです。
コンポーネントで useMutation
が呼び出されると、まずは MutationObserver
インスタンスが作成されます。
MutationObserver
インスタンスは、mutation 処理の状態を管理し、その変更を React に通知する役割を担います。
Step 2. useMutation のオプションが MutationObserver に登録される
MutationObserver
は useMutation
のオプションを保持する options
と mutate のオプションを保持する #mutateOptions
を別々のプロパティとしてを持っています。ここでは、setOptions
メソッドによって useMutation
のオプション (当然 onSuccess
等の callback を含む) が options
プロパティに登録されます。
Step 3. useSyncExternalStore により MutationObserver の変更が監視される
これにより、MutationObserver
の変更を検知して再レンダリングをトリガーできるようになります。
余談ですが、MutationObserver
の実装は、React に特有のものではなく、FW 間で共通です。だから MutationObserver
を外部ストアのように使って状態管理しているのですね。
② mutate が呼び出された際に起こること
Step 4. mutate のオプションが MutationObserver に登録される
コンポーネントで mutate
が呼び出されると、まずは MutationObserver
の mutate
メソッドが実行されます。
mutate
メソッドでは最初に、mutate
のオプションが MutationObserver
に登録されます。Step 2 では useMutation
のオプションが options
プロパティに登録されましたが、ここでは mutate
のオプションが #mutateOptions
に登録されます。
Step 5. Mutation インスタンスが作成されて MutationObserver に登録される
MutationObserver
の #currentMutation
プロパティに登録されます (L119-L121 の部分)。
また、同時に、Mutation
インスタンスの observers
プロパティに MutationObserver
が追加されます (L122 の addObserver
メソッド)。
これにより、Mutation
インスタンスは MutationObserver
に処理の結果を通知できるようになります。
Step 6. Mutation インスタンスの exec メソッドが実行される
exec
メソッドの全体像は以下の通りです。
以降で exec
メソッドの中身を追っていきます。
Step 7. #dispatch メソッドが呼び出され、ステータスが pending に変更される
#dispatch
メソッドの主な役割は、処理ステータスを更新し、MutationObserver
へ結果を通知することです。
Step 8. mutationFn で指定された関数が実行される
メインの mutation 処理です。以降、処理が成功した場合の流れのみ説明します。とはいえ、エラー発生時もあまり変わりません。
Step 9. MutationObserver の options に登録された onSuccess と onSettled が順に実行される
Step 2 で登録された useMutation callback がこのタイミングで実行されます。
Step 10. #dispatch メソッドが呼び出され、ステータスが success に変更される
Step 11. MutationObserver の #mutateOptions に登録された onSuccess と onSettled が順に実行される
#dispatch
メソッド内では、MutationObserver
の onMutationUpdate
メソッドが呼び出されています。この onMutationUpdate
メソッドの中ではさらに #notify
メソッドが呼び出されています。
#notify
メソッドでは、「hasListeners
が true」かつ「ステータスが success」の場合のみ、MutationObserver
の #mutateOptions
に登録された onSuccess
と onSettled
(= Step 4 で登録された mutate callback) が実行されます。
#notify(action?: Action<TData, TError, TVariables, TContext>): void {
notifyManager.batch(() => {
// First trigger the mutate callbacks
if (this.#mutateOptions && this.hasListeners()) { // hasListeners が true
const variables = this.#currentResult.variables!
const context = this.#currentResult.context
if (action?.type === 'success') { // status が success
this.#mutateOptions.onSuccess?.(action.data, variables, context!)
this.#mutateOptions.onSettled?.(action.data, null, variables, context)
「hasListeners
が true」の条件が何を指しているのかは後ほど説明します。
「ステータスが success」の条件はそのままの意味で、ステータスが success に変わった後にのみ、mutate callback が実行されることになります。
コードから読み取れる挙動の差異
上記から、useMutation callback と mutation callback には、以下のような挙動の差異があることが分かります。
実行時のステータスの差異
useMutation callback が実行される時のステータスは pending であるのに対し、mutate callback が実行される時のステータスは success/error です
これは、上記の Step 9-11 から確認できます。先に useMutation callback が一通り実行された後、ステータスが pending -> success/error に変更され、その後に mutate callback が実行されています。
mutation 処理実行中にコンポーネントがアンマウントされた場合の挙動の差異
useMutation callback は実行されるのに対し、mutate callback は実行されないです。
これは、上記の Step 11 で出てきた「hasListeners
が true」の条件が関係しています。
hasListners
で条件の対象となっている listner
とは、Step 3 の useSyncExternalStore
にて追加される、状態変化が起きた際に呼び出される callback 関数を指します。
実際のコードを再度見てみましょう。
// useMutation.ts
const result = React.useSyncExternalStore(
React.useCallback(
(onStoreChange) =>
observer.subscribe(notifyManager.batchCalls(onStoreChange)),
[observer],
),
() => observer.getCurrentResult(),
() => observer.getCurrentResult(),
)
observer.subscribe
は以下のようになっています (Subscribable
は MutationObserver
の継承元)。
export class Subscribable<TListener extends Function> {
protected listeners = new Set<TListener>()
constructor() {
this.subscribe = this.subscribe.bind(this)
}
subscribe(listener: TListener): () => void {
this.listeners.add(listener)
this.onSubscribe()
return () => {
this.listeners.delete(listener)
this.onUnsubscribe()
}
}
hasListeners(): boolean {
return this.listeners.size > 0
}
// 以下略
}
上記のように listners
に listner
が登録されるわけですが、subscribe
関数は、listners
から listner
を削除するクリーンアップ関数を返しています。
したがって、コンポーネントがアンマウントされる等によって上記のクリーンアップ関数が実行されると、登録されている listner
の数が 0 となり、hasListners
が false となるため、mutate callback の実行はスキップされるというわけです。
一方、useMutation callback 実行時はこのようなチェックが入らないため、コンポーネントがアンマウントされても問題なく実行されます。
mutate が複数同時に実行された場合の挙動の差異
useMutation callback は各 mutate 処理後に実行されるのに対し、mutate callback は最後に実行された mutate のものだけが実行されます。
こちらは、Step 5 の処理が関係しています。
改めて Step 5 のコードを見てみましょう。
this.#currentMutation?.removeObserver(this)
this.#currentMutation = this.#client
.getMutationCache()
.build(this.#client, this.options)
this.#currentMutation.addObserver(this)
もし mutate
が 2 回連続で呼び出された場合、removeObserver
メソッドにより、1 回目の呼び出し時に作成された Mutation
インスタンスから MutationObserver
が削除されます。
そして、Mutation
インスタンスでは、#dispatch
関数にて以下のように MutationObserver
の onMutationUpdate
メソッドが実行され、その中で mutate callback が実行されていました。
notifyManager.batch(() => {
this.#observers.forEach((observer) => {
observer.onMutationUpdate(action)
})
this.#mutationCache.notify({
mutation: this,
type: 'updated',
action,
})
})
removeObserver
によって #observers
が空になっているため、forEach
内の onMutationUpdate
メソッドが実行されず、したがって mutate callback も実行されません。
このような理由から、mutate callback については、最後に実行された mutate の物だけが実行されることになります。
一方、useMutation callback は exec
メソッドの流れの中で実行されるため、このような事象は発生せず、mutate 実行ごとに実行されます。
使い分けが必要な場面
これまでに説明した useMutation callback と mutate callback の差異をふまえ、両者を使い分ける必要がある場面の例を 3 つ紹介します。
Optimistic Updates Via the UI
TanStack Query v5 では、variables
と isPending
を使い、従来よりも軽量に楽観的更新を実装できるようになっています。
参考: https://tanstack.com/query/v5/docs/framework/react/guides/optimistic-updates#via-the-ui
例えば、以下のような実装が可能です。
import { Chip } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getPinStatus, updatePinStatus } from "../api/optimistic-update";
export const OptimisticUpdateExample = () => {
const queryClient = useQueryClient();
const { data } = useQuery({ queryKey: ["pinStatus"], queryFn: getPinStatus });
const { mutate, isPending } = useMutation({
mutationFn: () => updatePinStatus(!data?.pinStatus),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["pinStatus"] }),
});
const handleToggle = () => mutate();
const displayPinnedStatus = isPending ? !data?.pinStatus : data?.pinStatus;
return (
<Chip checked={displayPinnedStatus} onChange={handleToggle}>
{displayPinnedStatus ? "Pinned" : "Unpinned"}
</Chip>
);
};
こちらの実装では、isPending
が true の間は更新予定の値を表示しています。そして、更新完了後、onSettled
に設定したキャッシュクリア処理が実行されて再フェッチが走ります。再フェッチが完了すると isPending
が false になり、再フェッチした値を表示するように切り替わるという流れです。
さて、こちらの実装では useMutation onSettled を使用していますが、mutate onSettled を使うとどうなるでしょうか。
const { mutate, isPending } = useMutation({
mutationFn: () => updatePinStatus(!data?.pinStatus),
- onSuccess: () => queryClient.invalidateQueries({ queryKey: ["pinStatus"] }),
});
- const handleToggle = () => mutate();
+ const handleToggle = () =>
+ mutate(undefined, {
+ onSuccess: () =>
+ queryClient.invalidateQueries({ queryKey: ["pinStatus"] }),
+ });
一瞬古い値が表示されてしまいますね。これは、ステータスが pending -> success に切り替わった後に mutate callback が実行されるためです。キャッシュクリア処理が開始される時には既に isPending
が false となっており、再フェッチが完了するまでの間に古い値が表示されてしまうのです。
コンポーネントがアンマウントされても実行される必要がある処理
処理中にコンポーネントがアンマウントされた場合、useMutation callback は実行されるのに対し、mutate callback は実行されないのでした。
以下の実装で実際に挙動を確認してみましょう。
import { Button, Grid } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
export const UnmountExample = () => {
const [isOpen, setIsOpen] = useState(false);
const toggleOpen = () => setIsOpen((prev) => !prev);
return (
<Grid w="200px">
<Grid.Col span={isOpen ? 6 : 12}>
<Button onClick={toggleOpen} w={100}>
{isOpen ? "Close <-" : "Open ->"}
</Button>
</Grid.Col>
{isOpen && <SecondColumn />}
</Grid>
);
};
const SecondColumn = () => {
const { mutate } = useMutation({
mutationFn: async () =>
await new Promise((resolve) => setTimeout(resolve, 3000)),
onSuccess: () =>
notifications.show({ message: "Success", position: "bottom-left" }),
});
const handleUpdate = () => mutate();
return (
<Grid.Col span={6}>
<Button onClick={handleUpdate} w={100}>
Update
</Button>
</Grid.Col>
);
};
こちらの実装では、Open
ボタンをクリックすると右側にパネルが開きます。パネルにある Update
ボタンを押すと、3 秒間かかる mutation 処理が走り、完了後にトーストが表示されます。また、Close
ボタンを押すと、パネルのコンポーネントがアンマウントされます。
useMutation callback であれば、mutation 処理中にコンポーネントがアンマウントされたとしても、問題なくトーストが表示されることが確認できます。
一方、mutate callback の場合、処理中にコンポーネントが unmount されるとトーストは表示されません。
const { mutate } = useMutation({
mutationFn: async () =>
await new Promise((resolve) => setTimeout(resolve, 3000)),
- onSuccess: () =>
- notifications.show({ message: "Success", position: "bottom-left" }),
});
- const handleUpdate = () => mutate();
+ const handleUpdate = () =>
+ mutate(undefined, {
+ onSuccess: () =>
+ notifications.show({ message: "Success", position: "bottom-left" }),
+ });
したがって、mutation 処理後に必ず実行される必要がある処理は、useMutation callback で指定した方が良いということになります。
例えば、以下のような場合は、useMutation callback を使う方が良いでしょう。
-
invalidateQueries
でキャッシュを無効化する場合 - グローバルステートの更新が必要な場合
- 監視ツールにログを送信する場合
などなど
mutate が複数同時に実行されることが想定される場合
mutate が複数同時に実行された場合、useMutation callback は各 mutate 処理後に実行されるのに対し、mutate callback は最後に実行された mutate のものだけが実行されるのでした。
こちらも以下の実装で挙動を確認してみましょう
import { Button } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useMutation } from "@tanstack/react-query";
export const MultipleCallsExample = () => {
const { mutate } = useMutation({
mutationFn: async () =>
await new Promise((resolve) => setTimeout(resolve, 3000)),
onSuccess: () =>
notifications.show({ message: "Success", position: "bottom-left" }),
});
const handleUpdate = () => mutate();
return (
<Button onClick={handleUpdate} w={100}>
Update
</Button>
);
};
こちらの実装は、Update
ボタンを押すと 3 秒間かかる mutation 処理が走り、完了後にトーストが表示されるというシンプルなものです。
ボタンを連打してみると、useMutation callback を使用している場合は連打した分だけトーストが表示されます。
一方、mutate callback を使うと、最後の処理が完了した時にだけ callback が実行されるため、トーストの波に襲われずにすみます。
const { mutate } = useMutation({
mutationFn: async () =>
await new Promise((resolve) => setTimeout(resolve, 3000)),
- onSuccess: () =>
- notifications.show({ message: "Success", position: "bottom-left" }),
});
- const handleUpdate = () => mutate();
+ const handleUpdate = () =>
+ mutate(undefined, {
+ onSuccess: () =>
+ notifications.show({ message: "Success", position: "bottom-left" }),
+ });
このように、mutate が複数同時に実行されるような場面が想定される場合は、useMutation callback と mutate callback どちらを使うべきか考慮した方が良いかもしれません。
毎回実行される方が良いのであれば useMutation callback を使う方が良いですし、最後の一度だけ実行される方が良いのであれば mutate callback を使う方が良いでしょう。
おわりに
本記事の内容に誤りを見つけた場合は、コメントや X にて気軽にご指摘ください。
また、「他にもこんな挙動の違いがあるよー」みたいなのがあれば、コメントで知見共有していただけると嬉しいです。
本記事で紹介したサンプルコードは下記のリポジトリに格納しています。必要に応じてご参照ください。
Discussion