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で既往歴の一覧を再取得します。エラー時は元に戻します。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); } },