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);
}
},