オプティミスティックUI
Amplify DataはTanStack Queryと一緒に使用して、オプティミスティックUIを実装できます。これにより、CRUD操作がリクエストのラウンドトリップが完了する前に、UIに即座にレンダリングされます。Amplify DataをTanStackと一緒に使用すると、ローディング状態とエラー状態を簡単にレンダリングでき、APIコールが失敗した場合はUIの変更をロールバックできます。
以下の例では、新しく作成されたアイテムをオプティミスティックにレンダリングするリストビューと、更新と削除をオプティミスティックにレンダリングするディテールビューを作成します。
開始するには、Reactフロントエンドを持つ既存のAmplifyプロジェクトで以下のコマンドを実行します:
npm add @tanstack/react-query && \npm add --save-dev @tanstack/react-query-devtoolsDataスキーマを修正して、この「Real Estate Property」の例を使用します:
const schema = a.schema({ RealEstateProperty: a.model({ name: a.string().required(), address: a.string(), }).authorization(allow => [allow.guest()])})
export type Schema = ClientSchema<typeof schema>;
export const data = defineData({ schema, authorizationModes: { defaultAuthorizationMode: 'iam', },});ファイルを保存し、npx ampx sandboxを実行してバックエンドのクラウドサンドボックスに変更をデプロイします。このガイドでは、Real Estate Propertyリスティングアプリケーションを構築します。
次に、プロジェクトのルートで、必要なTanStack Queryインポートを追加し、クライアントを作成します:
import React from 'react'import ReactDOM from 'react-dom/client'import App from './App.tsx'import './index.css'import { Amplify } from 'aws-amplify'import outputs from '../amplify_outputs.json'import { QueryClient, QueryClientProvider } from "@tanstack/react-query";import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
Amplify.configure(outputs)
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> <QueryClientProvider client={queryClient}> <App /> <ReactQueryDevtools initialIsOpen={false} /> </QueryClientProvider> </React.StrictMode>,)Amplify Data APIでTanStack Query クエリキーを使用する方法
TanStack Queryは、指定したクエリキーに基づいてクエリキャッシュを管理します。クエリキーは配列である必要があります。配列には単一の文字列、または複数の文字列とネストされたオブジェクトを含めることができます。クエリキーはシリアライズ可能で、クエリのデータに一意である必要があります。
TanStackを使用してAmplify DataでオプティミスティックUIをレンダリングする場合、APIの操作に応じて異なるクエリキーを使用する必要があります。アイテムのリストを取得する場合は、単一の文字列が使用されます(例: queryKey: ["realEstateProperties"])。このクエリキーは、新しく作成されたアイテムをオプティミスティックにレンダリングする場合にも使用されます。アイテムを更新または削除する場合、クエリキーには削除または更新されるレコードの一意の識別子も含める必要があります(例: queryKey: ["realEstateProperties", newRealEstateProperty.id"])。
クエリキーの詳細については、TanStack Queryドキュメントを参照してください。
レコードのリストをオプティミスティックにレンダリングする
Amplify Data APIから返されたアイテムのリストをオプティミスティックにレンダリングするには、TanStackのuseQueryフックを使用して、Data APIクエリをqueryFnパラメータとして渡します。次の例は、APIからすべてのレコードを取得するクエリを作成します。クエリキーとしてrealEstatePropertiesを使用します。これは、新しく作成されたアイテムをオプティミスティックにレンダリングする場合に使用する同じキーです。
import type { Schema } from '../amplify/data/resource'import { generateClient } from 'aws-amplify/data'import { useQuery } from '@tanstack/react-query'
const client = generateClient<Schema>();
function App() { const { data: realEstateProperties, isLoading, isSuccess, isError: isErrorQuery, } = useQuery({ queryKey: ["realEstateProperties"], queryFn: async () => { const response = await client.models.RealEstateProperty.list();
const allRealEstateProperties = response.data;
if (!allRealEstateProperties) return null;
return allRealEstateProperties; }, }); // return ...}新しく作成されたレコードをオプティミスティックにレンダリングする
Amplify Data APIから返された新しく作成されたレコードをオプティミスティックにレンダリングするには、TanStackのuseMutationフックを使用して、Amplify Data API 変更をラッパーのmutationFnパラメータとして渡します。useQueryフックで使用されたものと同じクエリキー(realEstateProperties)を、新しく作成されたアイテムをオプティミスティックにレンダリングするためのクエリキーとして使用します。
onMutate関数を使用してキャッシュを直接更新し、onError関数を使用してリクエストが失敗した場合の変更をロールバックします。
import { generateClient } from 'aws-amplify/api'import type { Schema } from '../amplify/data/resource'import { useQueryClient, useMutation } from '@tanstack/react-query'
const client = generateClient<Schema>()
function App() { const queryClient = useQueryClient();
const createMutation = useMutation({ mutationFn: async (input: { name: string, address: string }) => { const { data: newRealEstateProperty } = await client.models.RealEstateProperty.create(input) return newRealEstateProperty; }, // mutateが呼び出されたとき: onMutate: async (newRealEstateProperty) => { // 発信中のrefetchをキャンセルする // (オプティミスティック更新を上書きしないようにするため) await queryClient.cancelQueries({ queryKey: ["realEstateProperties"] });
// 前の値をスナップショット const previousRealEstateProperties = queryClient.getQueryData([ "realEstateProperties", ]);
// オプティミスティックに新しい値に更新 if (previousRealEstateProperties) { queryClient.setQueryData(["realEstateProperties"], (old: Schema["RealEstateProperty"]["type"][]) => [ ...old, newRealEstateProperty, ]); }
// スナップショット値を持つコンテキストオブジェクトを返す return { previousRealEstateProperties }; }, // 変更が失敗した場合、 // onMutateから返されたコンテキストを使用してロールバック onError: (err, newRealEstateProperty, context) => { console.error("Error saving record:", err, newRealEstateProperty); if (context?.previousRealEstateProperties) { queryClient.setQueryData( ["realEstateProperties"], context.previousRealEstateProperties ); } }, // エラーまたは成功後は常にrefetch: onSettled: () => { queryClient.invalidateQueries({ queryKey: ["realEstateProperties"] }); }, }); // return ...}TanStack Queryで単一のアイテムをクエリする
単一のアイテムの更新をオプティミスティックにレンダリングするために、まずAPIからアイテムを取得します。useQueryフックを使用して、getクエリをqueryFnパラメータとして渡します。クエリキーについては、realEstatePropertiesとレコードの一意の識別子の組み合わせを使用します。
import { generateClient } from 'aws-amplify/data'import type { Schema } from '../amplify/data/resource'import { useQuery } from '@tanstack/react-query'
const client = generateClient<Schema>()
function App() { const currentRealEstatePropertyId = "SOME_ID" const { data: realEstateProperty, isLoading, isSuccess, isError: isErrorQuery, } = useQuery({ queryKey: ["realEstateProperties", currentRealEstatePropertyId], queryFn: async () => { if (!currentRealEstatePropertyId) { return }
const { data: property } = await client.models.RealEstateProperty.get({ id: currentRealEstatePropertyId, }); return property; }, });}レコードの更新をオプティミスティックにレンダリングする
単一のレコードに対するAmplify Data更新をオプティミスティックにレンダリングするには、TanStackのuseMutationフックを使用して、更新変更をmutationFnパラメータとして渡します。単一レコードuseQueryフックで使用されたのと同じクエリキーの組み合わせ(realEstatePropertiesとレコードのid)を、更新をオプティミスティックにレンダリングするためのクエリキーとして使用します。
onMutate関数を使用してキャッシュを直接更新し、onError関数を使用してリクエストが失敗した場合の変更をロールバックします。
import { generateClient } from 'aws-amplify/data'import type { Schema } from '../amplify/data/resource'import { useQueryClient, useMutation } from "@tanstack/react-query";
const client = generateClient<Schema>()
function App() { const queryClient = useQueryClient();
const updateMutation = useMutation({ mutationFn: async (realEstatePropertyDetails: { id: string, name?: string, address?: string }) => { const { data: updatedProperty } = await client.models.RealEstateProperty.update(realEstatePropertyDetails);
return updatedProperty; }, // mutateが呼び出されたとき: onMutate: async (newRealEstateProperty: { id: string, name?: string, address?: string }) => { // 発信中のrefetchをキャンセルする // (オプティミスティック更新を上書きしないようにするため) await queryClient.cancelQueries({ queryKey: ["realEstateProperties", newRealEstateProperty.id], });
await queryClient.cancelQueries({ queryKey: ["realEstateProperties"], });
// 前の値をスナップショット const previousRealEstateProperty = queryClient.getQueryData([ "realEstateProperties", newRealEstateProperty.id, ]);
// オプティミスティックに新しい値に更新 if (previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", newRealEstateProperty.id], /** * `newRealEstateProperty`は最初、レコードの更新された値のみを含みます。 * UIで更新されたフィールドのみのオプティミスティック値をレンダリング * するのを避けるために、すべてのフィールドの以前の値を含めてください: */ { ...previousRealEstateProperty, ...newRealEstateProperty } ); }
// 前と新しいrealEstatePropertyを含むコンテキストを返す return { previousRealEstateProperty, newRealEstateProperty }; }, // 変更が失敗した場合、上で返されたコンテキストを使用 onError: (err, newRealEstateProperty, context) => { console.error("Error updating record:", err, newRealEstateProperty); if (context?.previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", context.newRealEstateProperty.id], context.previousRealEstateProperty ); } }, // エラーまたは成功後は常にrefetch: onSettled: (newRealEstateProperty) => { if (newRealEstateProperty) { queryClient.invalidateQueries({ queryKey: ["realEstateProperties", newRealEstateProperty.id], }); queryClient.invalidateQueries({ queryKey: ["realEstateProperties"], }); } }, });}レコードの削除をオプティミスティックにレンダリングする
単一のレコードの削除をオプティミスティックにレンダリングするには、TanStackのuseMutationフックを使用して、削除変更をmutationFnパラメータとして渡します。単一レコードuseQueryフックで使用されたのと同じクエリキーの組み合わせ(realEstatePropertiesとレコードのid)を、更新をオプティミスティックにレンダリングするためのクエリキーとして使用します。
onMutate関数を使用してキャッシュを直接更新し、onError関数を使用して削除が失敗した場合の変更をロールバックします。
import { generateClient } from 'aws-amplify/data'import type { Schema } from '../amplify/data/resource'import { useQueryClient, useMutation } from '@tanstack/react-query'
const client = generateClient<Schema>()
function App() { const queryClient = useQueryClient();
const deleteMutation = useMutation({ mutationFn: async (realEstatePropertyDetails: { id: string }) => { const { data: deletedProperty } = await client.models.RealEstateProperty.delete(realEstatePropertyDetails); return deletedProperty; }, // mutateが呼び出されたとき: onMutate: async (newRealEstateProperty) => { // 発信中のrefetchをキャンセルする // (オプティミスティック更新を上書きしないようにするため) await queryClient.cancelQueries({ queryKey: ["realEstateProperties", newRealEstateProperty.id], });
await queryClient.cancelQueries({ queryKey: ["realEstateProperties"], });
// 前の値をスナップショット const previousRealEstateProperty = queryClient.getQueryData([ "realEstateProperties", newRealEstateProperty.id, ]);
// オプティミスティックに新しい値に更新 if (previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", newRealEstateProperty.id], newRealEstateProperty ); }
// 前と新しいrealEstatePropertyを含むコンテキストを返す return { previousRealEstateProperty, newRealEstateProperty }; }, // 変更が失敗した場合、上で返されたコンテキストを使用 onError: (err, newRealEstateProperty, context) => { console.error("Error deleting record:", err, newRealEstateProperty); if (context?.previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", context.newRealEstateProperty.id], context.previousRealEstateProperty ); } }, // エラーまたは成功後は常にrefetch: onSettled: (newRealEstateProperty) => { if (newRealEstateProperty) { queryClient.invalidateQueries({ queryKey: ["realEstateProperties", newRealEstateProperty.id], }); queryClient.invalidateQueries({ queryKey: ["realEstateProperties"], }); } }, });}オプティミスティックレンダリングされたデータのローディング状態とエラー状態
useQueryとuseMutationの両方は、クエリまたは変更の現在の状態を示すisLoadingとisErrorの状態を返します。これらの状態を使用して、ローディングとエラーインジケータをレンダリングできます。
操作固有のローディング状態に加えて、TanStack QueryはuseIsFetchingフックを提供します。このデモの目的のため、完全な例では、バックグラウンドでのフェッチを含む任意のクエリがフェッチされている場合に、TanStackがバックグラウンドで何をしているかを視覚化するのに役立つグローバルローディングインジケータを表示しています:
function GlobalLoadingIndicator() { const isFetching = useIsFetching(); return isFetching ? <div style={styles.globalLoadingIndicator}></div> : null;}TanStack Queryフックの高度な使用方法の詳細については、TanStackドキュメントを参照してください。
次の例は、TanStackから返された状態を使用して、変更進行中のローディングインジケータとエラーメッセージをレンダリングする方法を示しています。その他の例については、下の完全な例を参照してください。
<> {updateMutation.isError && updateMutation.error instanceof Error ? ( <div>An error occurred: {updateMutation.error.message}</div> ) : null}
{updateMutation.isSuccess ? ( <div>Real Estate Property updated!</div> ) : null}
<button onClick={() => updateMutation.mutate({ id: realEstateProperty.id, address: `${Math.floor( 1000 + Math.random() * 9000 )} Main St`, }) } > Update Address </button></>完全な例
import React from 'react'import ReactDOM from 'react-dom/client'import App from './App.tsx'import './index.css'import { Amplify } from 'aws-amplify'import outputs from '../amplify_outputs.json'import { QueryClient, QueryClientProvider } from "@tanstack/react-query";import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
Amplify.configure(outputs)
export const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> <QueryClientProvider client={queryClient}> <App /> <ReactQueryDevtools initialIsOpen={false} /> </QueryClientProvider> </React.StrictMode>,)import { generateClient } from 'aws-amplify/data'import type { Schema } from '../amplify/data/resource'import './App.css'import { useIsFetching, useMutation, useQuery } from '@tanstack/react-query'import { queryClient } from './main'import { useState } from 'react'
const client = generateClient<Schema>({ authMode: 'iam'})
function GlobalLoadingIndicator() { const isFetching = useIsFetching();
return isFetching ? <div style={styles.globalLoadingIndicator}></div> : null;}
function App() { const [currentRealEstatePropertyId, setCurrentRealEstatePropertyId] = useState<string | null>(null);
const { data: realEstateProperties, isLoading, isSuccess, isError: isErrorQuery, } = useQuery({ queryKey: ["realEstateProperties"], queryFn: async () => { const response = await client.models.RealEstateProperty.list();
const allRealEstateProperties = response.data;
if (!allRealEstateProperties) return null;
return allRealEstateProperties; }, });
const createMutation = useMutation({ mutationFn: async (input: { name: string, address: string }) => { const { data: newRealEstateProperty } = await client.models.RealEstateProperty.create(input) return newRealEstateProperty; }, // mutateが呼び出されたとき: onMutate: async (newRealEstateProperty) => { // 発信中のrefetchをキャンセルする // (オプティミスティック更新を上書きしないようにするため) await queryClient.cancelQueries({ queryKey: ["realEstateProperties"] });
// 前の値をスナップショット const previousRealEstateProperties = queryClient.getQueryData([ "realEstateProperties", ]);
// オプティミスティックに新しい値に更新 if (previousRealEstateProperties) { queryClient.setQueryData(["realEstateProperties"], (old: Schema["RealEstateProperty"]["type"][]) => [ ...old, newRealEstateProperty, ]); }
// スナップショット値を持つコンテキストオブジェクトを返す return { previousRealEstateProperties }; }, // 変更が失敗した場合、 // onMutateから返されたコンテキストを使用してロールバック onError: (err, newRealEstateProperty, context) => { console.error("Error saving record:", err, newRealEstateProperty); if (context?.previousRealEstateProperties) { queryClient.setQueryData( ["realEstateProperties"], context.previousRealEstateProperties ); } }, // エラーまたは成功後は常にrefetch: onSettled: () => { queryClient.invalidateQueries({ queryKey: ["realEstateProperties"] }); }, });
function RealEstatePropertyDetailView() {
const { data: realEstateProperty, isLoading, isSuccess, isError: isErrorQuery, } = useQuery({ queryKey: ["realEstateProperties", currentRealEstatePropertyId], queryFn: async () => { if (!currentRealEstatePropertyId) { return }
const { data: property } = await client.models.RealEstateProperty.get({ id: currentRealEstatePropertyId }); return property }, });
const updateMutation = useMutation({ mutationFn: async (realEstatePropertyDetails: { id: string, name?: string, address?: string }) => { const { data: updatedProperty } = await client.models.RealEstateProperty.update(realEstatePropertyDetails);
return updatedProperty; }, // mutateが呼び出されたとき: onMutate: async (newRealEstateProperty: { id: string, name?: string, address?: string }) => { // 発信中のrefetchをキャンセルする // (オプティミスティック更新を上書きしないようにするため) await queryClient.cancelQueries({ queryKey: ["realEstateProperties", newRealEstateProperty.id], });
await queryClient.cancelQueries({ queryKey: ["realEstateProperties"], });
// 前の値をスナップショット const previousRealEstateProperty = queryClient.getQueryData([ "realEstateProperties", newRealEstateProperty.id, ]);
// オプティミスティックに新しい値に更新 if (previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", newRealEstateProperty.id], /** * `newRealEstateProperty`は最初、レコードの更新された値のみを含みます。 * UIで更新されたフィールドのみのオプティミスティック値をレンダリング * するのを避けるために、すべてのフィールドの以前の値を含めてください: */ { ...previousRealEstateProperty, ...newRealEstateProperty } ); }
// 前と新しいrealEstatePropertyを含むコンテキストを返す return { previousRealEstateProperty, newRealEstateProperty }; }, // 変更が失敗した場合、上で返されたコンテキストを使用 onError: (err, newRealEstateProperty, context) => { console.error("Error updating record:", err, newRealEstateProperty); if (context?.previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", context.newRealEstateProperty.id], context.previousRealEstateProperty ); } }, // エラーまたは成功後は常にrefetch: onSettled: (newRealEstateProperty) => { if (newRealEstateProperty) { queryClient.invalidateQueries({ queryKey: ["realEstateProperties", newRealEstateProperty.id], }); queryClient.invalidateQueries({ queryKey: ["realEstateProperties"], }); } }, });
const deleteMutation = useMutation({ mutationFn: async (realEstatePropertyDetails: { id: string }) => { const { data: deletedProperty } = await client.models.RealEstateProperty.delete(realEstatePropertyDetails); return deletedProperty; }, // mutateが呼び出されたとき: onMutate: async (newRealEstateProperty) => { // 発信中のrefetchをキャンセルする // (オプティミスティック更新を上書きしないようにするため) await queryClient.cancelQueries({ queryKey: ["realEstateProperties", newRealEstateProperty.id], });
await queryClient.cancelQueries({ queryKey: ["realEstateProperties"], });
// 前の値をスナップショット const previousRealEstateProperty = queryClient.getQueryData([ "realEstateProperties", newRealEstateProperty.id, ]);
// オプティミスティックに新しい値に更新 if (previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", newRealEstateProperty.id], newRealEstateProperty ); }
// 前と新しいrealEstatePropertyを含むコンテキストを返す return { previousRealEstateProperty, newRealEstateProperty }; }, // 変更が失敗した場合、上で返されたコンテキストを使用 onError: (err, newRealEstateProperty, context) => { console.error("Error deleting record:", err, newRealEstateProperty); if (context?.previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", context.newRealEstateProperty.id], context.previousRealEstateProperty ); } }, // エラーまたは成功後は常にrefetch: onSettled: (newRealEstateProperty) => { if (newRealEstateProperty) { queryClient.invalidateQueries({ queryKey: ["realEstateProperties", newRealEstateProperty.id], }); queryClient.invalidateQueries({ queryKey: ["realEstateProperties"], }); } }, });
return ( <div style={styles.detailViewContainer}> <h2>Real Estate Property Detail View</h2> {isErrorQuery && <div>{"Problem loading Real Estate Property"}</div>} {isLoading && ( <div style={styles.loadingIndicator}> {"Loading Real Estate Property..."} </div> )} {isSuccess && ( <div> <p>{`Name: ${realEstateProperty?.name}`}</p> <p>{`Address: ${realEstateProperty?.address}`}</p> </div> )} {realEstateProperty && ( <div> <div> {updateMutation.isPending ? ( "Updating Real Estate Property..." ) : ( <> {updateMutation.isError && updateMutation.error instanceof Error ? ( <div>An error occurred: {updateMutation.error.message}</div> ) : null}
{updateMutation.isSuccess ? ( <div>Real Estate Property updated!</div> ) : null}
<button onClick={() => updateMutation.mutate({ id: realEstateProperty.id, name: `Updated Home ${Date.now()}`, }) } > Update Name </button> <button onClick={() => updateMutation.mutate({ id: realEstateProperty.id, address: `${Math.floor( 1000 + Math.random() * 9000 )} Main St`, }) } > Update Address </button> </> )} </div>
<div> {deleteMutation.isPending ? ( "Deleting Real Estate Property..." ) : ( <> {deleteMutation.isError && deleteMutation.error instanceof Error ? ( <div>An error occurred: {deleteMutation.error.message}</div> ) : null}
{deleteMutation.isSuccess ? ( <div>Real Estate Property deleted!</div> ) : null}
<button onClick={() => deleteMutation.mutate({ id: realEstateProperty.id, }) } > Delete </button> </> )} </div> </div> )} <button onClick={() => setCurrentRealEstatePropertyId(null)}> Back </button> </div> );
} return ( <div> {!currentRealEstatePropertyId && ( <div style={styles.appContainer}> <h1>Real Estate Properties:</h1> <div> {createMutation.isPending ? ( "Adding Real Estate Property..." ) : ( <> {createMutation.isError && createMutation.error instanceof Error ? ( <div>An error occurred: {createMutation.error.message}</div> ) : null}
{createMutation.isSuccess ? ( <div>Real Estate Property added!</div> ) : null}
<button onClick={() => { createMutation.mutate({ name: `New Home ${Date.now()}`, address: `${Math.floor( 1000 + Math.random() * 9000 )} Main St`, }); }} > Add RealEstateProperty </button> </> )} </div> <ul style={styles.propertiesList}> {isLoading && ( <div style={styles.loadingIndicator}> {"Loading Real Estate Properties..."} </div> )} {isErrorQuery && ( <div>{"Problem loading Real Estate Properties"}</div> )} {isSuccess && realEstateProperties?.map((realEstateProperty, idx) => { if (!realEstateProperty) return null; return ( <li style={styles.listItem} key={`${idx}-${realEstateProperty.id}`} > <p>{realEstateProperty.name}</p> <button style={styles.detailViewButton} onClick={() => setCurrentRealEstatePropertyId(realEstateProperty.id) } > Detail View </button> </li> ); })} </ul> </div> )} {currentRealEstatePropertyId && <RealEstatePropertyDetailView />} <GlobalLoadingIndicator /> </div> );
}
export default App
const styles = { appContainer: { display: "flex", flexDirection: "column", alignItems: "center", }, detailViewButton: { marginLeft: "1rem" }, detailViewContainer: { border: "1px solid black", padding: "3rem" }, globalLoadingIndicator: { position: "fixed", top: 0, left: 0, width: "100%", height: "100%", border: "4px solid blue", pointerEvents: "none", }, listItem: { display: "flex", justifyContent: "space-between", border: "1px dotted grey", padding: ".5rem", margin: ".1rem", }, loadingIndicator: { border: "1px solid black", padding: "1rem", margin: "1rem", }, propertiesList: { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "start", width: "50%", border: "1px solid black", padding: "1rem", listStyleType: "none", },} as const;