Zenn
🙀

[TanStack Query v5] useMutation callback と mutate callback の挙動の違い

2025/03/21に公開
2

こんにちは、株式会社カナリーでソフトウェアエンジニアをやっている matsu です。

先日 TanStack Query の mutation 処理で思わぬ落とし穴に遭遇したのですが、ドキュメントや解説記事が見当たらなかったので、本記事にて知見を共有します 🐈‍⬛

はじめに

TanStack Query の mutation 処理では、onSuccess / onError / onSettled オプションにより、mutation 処理後に実行される callback 関数を登録できます。

これらの callback 関数は、React の場合、useMutationmutate (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 の全体像は以下の通りです。

https://github.com/TanStack/query/blob/v5.67.0/packages/react-query/src/useMutation.ts#L15-L65

コンポーネントで useMutation が呼び出されると、まずは MutationObserver インスタンスが作成されます。

https://github.com/TanStack/query/blob/v5.67.0/packages/react-query/src/useMutation.ts#L26-L32

MutationObserver インスタンスは、mutation 処理の状態を管理し、その変更を React に通知する役割を担います。

Step 2. useMutation のオプションが MutationObserver に登録される

https://github.com/TanStack/query/blob/v5.67.0/packages/react-query/src/useMutation.ts#L34-L36

MutationObserveruseMutation のオプションを保持する options と mutate のオプションを保持する #mutateOptions を別々のプロパティとしてを持っています。ここでは、setOptions メソッドによって useMutation のオプション (当然 onSuccess 等の callback を含む) が options プロパティに登録されます。

https://github.com/TanStack/query/blob/v5.67.0/packages/query-core/src/mutationObserver.ts#L22-L79

Step 3. useSyncExternalStore により MutationObserver の変更が監視される

https://github.com/TanStack/query/blob/v5.67.0/packages/react-query/src/useMutation.ts#L38-L46

これにより、MutationObserver の変更を検知して再レンダリングをトリガーできるようになります。

余談ですが、MutationObserver の実装は、React に特有のものではなく、FW 間で共通です。だから MutationObserver を外部ストアのように使って状態管理しているのですね。

② mutate が呼び出された際に起こること

Step 4. mutate のオプションが MutationObserver に登録される

コンポーネントで mutate が呼び出されると、まずは MutationObservermutate メソッドが実行されます。

https://github.com/TanStack/query/blob/v5.67.0/packages/react-query/src/useMutation.ts#L48-L55

mutate メソッドでは最初に、mutate のオプションが MutationObserver に登録されます。Step 2 では useMutation のオプションが options プロパティに登録されましたが、ここでは mutate のオプションが #mutateOptions に登録されます。

https://github.com/TanStack/query/blob/v5.67.0/packages/query-core/src/mutationObserver.ts#L111-L115

Step 5. Mutation インスタンスが作成されて MutationObserver に登録される

https://github.com/TanStack/query/blob/v5.67.0/packages/query-core/src/mutationObserver.ts#L117-L123

MutationObserver#currentMutation プロパティに登録されます (L119-L121 の部分)。
また、同時に、Mutation インスタンスの observers プロパティに MutationObserver が追加されます (L122 の addObserver メソッド)。

これにより、Mutation インスタンスは MutationObserver に処理の結果を通知できるようになります。

Step 6. Mutation インスタンスの exec メソッドが実行される

exec メソッドの全体像は以下の通りです。

https://github.com/TanStack/query/blob/v5.67.0/packages/query-core/src/mutation.ts#L164-L342

以降で exec メソッドの中身を追っていきます。

Step 7. #dispatch メソッドが呼び出され、ステータスが pending に変更される

https://github.com/TanStack/query/blob/v5.67.0/packages/query-core/src/mutation.ts#L192

#dispatch メソッドの主な役割は、処理ステータスを更新し、MutationObserver へ結果を通知することです。

https://github.com/TanStack/query/blob/v5.67.0/packages/query-core/src/mutation.ts#L273-L342

Step 8. mutationFn で指定された関数が実行される

https://github.com/TanStack/query/blob/v5.67.0/packages/query-core/src/mutation.ts#L208

メインの mutation 処理です。以降、処理が成功した場合の流れのみ説明します。とはいえ、エラー発生時もあまり変わりません。

Step 9. MutationObserver の options に登録された onSuccess と onSettled が順に実行される

Step 2 で登録された useMutation callback がこのタイミングで実行されます。

https://github.com/TanStack/query/blob/v5.67.0/packages/query-core/src/mutation.ts#L210-L229

Step 10. #dispatch メソッドが呼び出され、ステータスが success に変更される

https://github.com/TanStack/query/blob/v5.67.0/packages/query-core/src/mutation.ts#L231

Step 11. MutationObserver の #mutateOptions に登録された onSuccess と onSettled が順に実行される

#dispatch メソッド内では、MutationObserveronMutationUpdate メソッドが呼び出されています。この onMutationUpdate メソッドの中ではさらに #notify メソッドが呼び出されています。

https://github.com/TanStack/query/blob/v5.67.0/packages/query-core/src/mutation.ts#L333

https://github.com/TanStack/query/blob/v5.67.0/packages/query-core/src/mutationObserver.ts#L87-L91

https://github.com/TanStack/query/blob/v5.67.0/packages/query-core/src/mutationObserver.ts#L144-L169

#notify メソッドでは、「hasListeners が true」かつ「ステータスが success」の場合のみ、MutationObserver#mutateOptions に登録された onSuccessonSettled (= 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 3useSyncExternalStore にて追加される、状態変化が起きた際に呼び出される callback 関数を指します。
実際のコードを再度見てみましょう。

// useMutation.ts

const result = React.useSyncExternalStore(
  React.useCallback(
    (onStoreChange) =>
      observer.subscribe(notifyManager.batchCalls(onStoreChange)),
    [observer],
  ),
  () => observer.getCurrentResult(),
  () => observer.getCurrentResult(),
)

observer.subscribe は以下のようになっています (SubscribableMutationObserver の継承元)。

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
  }

  // 以下略
}

上記のように listnerslistner が登録されるわけですが、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 関数にて以下のように MutationObserveronMutationUpdate メソッドが実行され、その中で 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 では、variablesisPending を使い、従来よりも軽量に楽観的更新を実装できるようになっています。
参考: 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 にて気軽にご指摘ください。
また、「他にもこんな挙動の違いがあるよー」みたいなのがあれば、コメントで知見共有していただけると嬉しいです。

本記事で紹介したサンプルコードは下記のリポジトリに格納しています。必要に応じてご参照ください。
https://github.com/matsu3m/tanstack-query-playground-1

2
Canary Tech Blog

Discussion

ログインするとコメントできます