TanStack Query/React Query useMutationで楽観的更新の実装(React19, TanStack Query, Next.js)

今回はReact TanStack QueryのuseMutationを用いて、楽観的更新を実装する方法を紹介します。この記事ではTanStack Queryの機能で楽観的更新を実装しており、React独自のuseOptimistic hookは使用していません。
useOptimistic – React

TanStack Query

TanStack Queryとは、serverからのdata fetchingやclient cacheへのdata保存などを行うライブラリーです。Cacheのデータを更新することで、それに紐付いたcomponentの再レンダリングを行うこともできます。

TanStack Queryについて、こちらの記事がわかりやすかったです。
TanStack Queryとは?

公式documentを見てみましょう。
TanStack Query Quick Start

TanStack Queryを使用するために、QueryProviderを実装します。QueryClient instanceを生成して、それを利用するためのものです。

//@/components/query-provider.tsx
"use client";

import {
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";
import { useState } from "react";

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,  //60秒で古いデータと見なされる
        retry: 3,              //失敗時に3回までretry
      },
    },
  });
}

interface QueryProviderProps {
  children: React.ReactNode;
}

export const QueryProvider = ({ children }: QueryProviderProps) => {
  const [queryClient] = useState(() => makeQueryClient());

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};

これをroot layoutに設置していきます。

//@/app/layout.tsx
import { QueryProvider } from "@/components/query-provider";
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Next app",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        <QueryProvider>
          {children}
        </QueryProvider>
      </body>
    </html>
  );
}

これでTanStack Queryが使用できるようになりました。今回はCSRで利用していますが、SSRで利用する場合はhydrationという仕組みを使う必要があるようです。
TanStack Query hydration

useMutation

Next.jsのroute handlersを使用しています。今回はuserが自分の既往歴(past medical history)を記録するような機能を実装します。

Route handlers API

//@/app/api/past-medical-history
export async function POST(request: Request) {
  try {
    //POST時、つまり既往歴登録時の処理を記述
    return NextResponse.json({
      success: true,
      data: { ...data },
    });
  } catch (error) {
    //ここにエラー時の処理を記述
    return NextResponse.json(
      { success: false, error: errorMessage },
      { status: 500 }
    );
  }
}

useMutation

今回dataの非同期通信などをuseMutationを使って行います。この関数の返り値mutation objectによって、client cacheの更新とdata の通信(今回はPOST method)を行うことができます。
TanStack Query useMutation
mutation objectのpropertyには以下のようなものがあります。mutate methodやmutateAsync methodによってdataの通信を行います。使い方は後ほど解説します。

{
  //実行関数
  mutate: (data) => void,
  mutateAsync: (data) => Promise<TData>,

  reset: () => void,        // 状態をリセット

  // 状態
  isPending: boolean,        // 実行中かどうか
  isIdle: boolean,          // 未実行状態
  isError: boolean,         // エラー状態
  isSuccess: boolean,       // 成功状態
  failureCount: number,     // 失敗回数
  failureReason: Error | null,
  status: 'idle' | 'pending' | 'error' | 'success',

  // データ
  data: TData | undefined,  // 成功時のレスポンスデータ
  error: TError | null,     // エラー情報
}

mutation objectを返却するためのcustom hookを作っていきます。まずmutationFnにsetするための関数を作成します。mutate methodやmutateAsync methodが呼ばれた時にこの関数が実行されます。

//@/features/pmh/hooks/use-create-pmh.ts
const createPMH = async (request) => {
  const response = await fetch("/api/past-medical-history", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(request),
  });
  if (!response.ok) {
    throw new Error("Failed to create Past Medical History");
  }
  const data = await response.json();
  return data;
}

Custom hookの実装をしていきます。
useMutationの使い方としてgenericsにはresponse type, error type, request type, context typeの順番で代入していきます。4つ目のcontextというのはonMutateの返り値で、onSuccessやonErrorに渡されます。
useMutationの引数objectにmutationFn, onMutate, onSuccess, onErrorをsetします。onMutateはmutationFnが実行される前に実行される関数を記述します。mutationFnは実行関数で、onSuccessは成功時に実行される関数、onErrorはエラー時に実行される関数です。

import { useMutation, useQueryClient } from "@tanstack/react-query";

interface PMHRequestType {
  diseaseName: string;                 //診断名
  diagnosisDate: string | null;        //診断日
  primaryCareProvider: string | null;  //かかりつけ病院
  notes: string | null;                //メモ
  userId: string;
}

interface PMHResponseType extends PMHRequestType{
  recordId: string;
}

export const useCreatePMH = () => {
  const queryClient = useQueryClient();
  const mutation = useMutation<
    PMHResponseType,//成功時の返り値の型
    Error,          //Error型
    PMHRequestType, //mutationFnに渡す引数の型
    {
      previousPMHList: PMHResponseType[] | undefined;
      tempId: string
    }//onMutateの返り値の型, onSuccessやonErrorに渡される。
  >({
    mutationFn: createPMH, //ここに先ほど作成した関数をsetします。
    onMutate: () => {},    //mutationFn実行前に実行される。
    onSuccess: () => {},   //成功時に実行される。 
    onError: () => {}.     //エラー時に実行される。
    },
  });

  return mutation;
};

onMutate

まず、このcustom hook実行中にdataのrefetchが起こらないように、現在のTanStack Queryのqueryをcancelします。今回の例であれば、前回data取得からstaleTimeで設定した60秒を経過するとrefetchが起こってしまいます。
今回既往歴の一覧はqueryKey: “pmh-list”でcacheに保存してあるとします。適当なtempIdを作成し、setQueryData methodを用いてcacheにデータを保存します。cacheのデータが変更されると、これに紐付いたコンポーネントが自動的に再レンダリングされます。

onMutate: async (newPMH) => {
  //onMutateは、ミューテーションが開始される前に呼び出される関数。
  //引数newPMHはmutationFnに渡される引数と同じもの。登録された新しい既往歴。
  //まず現在のクエリーをキャンセルすることでrefetchで古いデータで上書きされるのを防ぐ。
  await queryClient.cancelQueries({ queryKey: ["pmh-list"] });
  const previousPMHList = queryClient.getQueryData<PMHResponseType[]>([
    "pmh-list",
  ]);
  //楽観的更新:以前のPMHListに新しいPMHを一時的なIDを付与して追加。それを一時的にキャッシュに保存することで、ユーザーに即座に反映されるようにする。
  //保存に成功すれば、キャッシュを正確なものに更新し、失敗した場合はrollbackする。
  const tempId = `temp-id-${Date.now()}`;

  queryClient.setQueryData<PMHResponseType[]>(["pmh-list"], (oldPMHList) => {
    //oldPMHListは古い既往歴の一覧。
    //新しい既往歴を一時的なidで保存する。
    const tempPMH: PMHResponseType = {
      ...newPMH,
      recordId: tempId, // 一時的なIDを生成
    };
    //oldPMHListに新しい一時的な既往歴を追加して返却することでcacheにデータを保存。
    return oldPMHList ? [tempPMH, ...oldPMHList] : [tempPMH];
  });

  return { previousPMHList, tempId };
},

これで楽観的更新の大部分は実装できました。あとは成功時と失敗時のエラーハンドリングを実装するだけです。

onSuccess, onError

成功時はinvalidateQueries methodで既往歴の一覧を再取得します。エラー時は元に戻します。onError関数の第三引き数contextはonMutateの返り値が入ってきます。

onSuccess: () => {
  //成功時, サーバーから正確なデータを取得。
  queryClient.invalidateQueries({ queryKey: ["pmh-list"] });
},
onError: (error, newPMH, context) => {
  console.error("Error creating PMH:", error);

  // エラーが発生した場合、楽観的更新を元に戻す
  if (context?.previousPMHList) {
    queryClient.setQueryData(["pmh-list"], context.previousPMHList);
  }
},

投稿者 Hiro

元医者でエンジニアを目指しています。卒後3年目。 Python (flask, tensorflow), JS・TS (React, Next.js), SQLなど使用。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です