TanStack Query/React Query useMutationで楽観的更新の実装(React19, TanStack Query, Next.js)
今回はReact TanStack QueryのuseMutationを用いて、楽観的更新を実装する方法を紹介します。この記事ではTanStack Queryの機能で楽観的更新を実装しており、React独自のuseOptimistic hookは使用していません。
useOptimistic – React
Contents
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で既往歴の一覧を再取得します。invalidateQueries methodは、データをstale状態(古い状態)に設定するmethodで、データのrefetchingが起こります。データ取得は別のところで実装しており、その関数が呼ばれDBから最新のデータを取得しに行きます。エラー時はonMutateの返り値であるcontextを使ってcacheを元に戻します。
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);
  }
},