Optimistic UI
Amplify Data can be used with TanStack Query to implement optimistic UI, allowing CRUD operations to be rendered immediately on the UI before the request roundtrip has completed. Using Amplify Data with TanStack additionally makes it easy to render loading and error states, and allows you to rollback changes on the UI when API calls are unsuccessful.
In the following examples we'll create a list view that optimistically renders newly created items, and a detail view that optimistically renders updates and deletes.
To get started, run the following command in an existing Amplify project with a React frontend:
# Install TanStack Querynpm i @tanstack/react-query @tanstack/react-query-devtools
Modify your Data schema to use this "Real Estate Property" example:
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', },});
Save the file and run npx ampx sandbox
to deploy the changes to your backend cloud sandbox. For the purposes of this guide, we'll build a Real Estate Property listing application.
Next, at the root of your project, add the required TanStack Query imports, and create a client:
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>,)
How to use TanStack Query query keys with the Amplify Data API
TanStack Query manages query caching based on the query keys you specify. A query key must be an array. The array can contain a single string or multiple strings and nested objects. The query key must be serializable, and unique to the query's data.
When using TanStack to render optimistic UI with Amplify Data, you must use different query keys depending on the API operation. When retrieving a list of items, a single string is used (e.g. queryKey: ["realEstateProperties"]
). This query key is also used to optimistically render a newly created item. When updating or deleting an item, the query key must also include the unique identifier for the record being deleted or updated (e.g. queryKey: ["realEstateProperties", newRealEstateProperty.id]
).
For more detailed information on query keys, see the TanStack Query documentation.
Optimistically rendering a list of records
To optimistically render a list of items returned from the Amplify Data API, use the TanStack useQuery
hook, passing in the Data API query as the queryFn
parameter. The following example creates a query to retrieve all records from the API. We'll use realEstateProperties
as the query key, which will be the same key we use to optimistically render a newly created item.
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 ...}
Optimistically rendering a newly created record
To optimistically render a newly created record returned from the Amplify Data API, use the TanStack useMutation
hook, passing in the Amplify Data API mutation as the mutationFn
parameter. We'll use the same query key used by the useQuery
hook (realEstateProperties
) as the query key to optimistically render a newly created item.
We'll use the onMutate
function to update the cache directly, as well as the onError
function to rollback changes when a request fails.
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; }, // When mutate is called: onMutate: async (newRealEstateProperty) => { // Cancel any outgoing refetches // (so they don't overwrite our optimistic update) await queryClient.cancelQueries({ queryKey: ["realEstateProperties"] });
// Snapshot the previous value const previousRealEstateProperties = queryClient.getQueryData([ "realEstateProperties", ]);
// Optimistically update to the new value if (previousRealEstateProperties) { queryClient.setQueryData(["realEstateProperties"], (old: Schema["RealEstateProperty"]["type"][]) => [ ...old, newRealEstateProperty, ]); }
// Return a context object with the snapshotted value return { previousRealEstateProperties }; }, // If the mutation fails, // use the context returned from onMutate to rollback onError: (err, newRealEstateProperty, context) => { console.error("Error saving record:", err, newRealEstateProperty); if (context?.previousRealEstateProperties) { queryClient.setQueryData( ["realEstateProperties"], context.previousRealEstateProperties ); } }, // Always refetch after error or success: onSettled: () => { queryClient.invalidateQueries({ queryKey: ["realEstateProperties"] }); }, }); // return ...}
Querying a single item with TanStack Query
To optimistically render updates on a single item, we'll first retrieve the item from the API. We'll use the useQuery
hook, passing in the get
query as the queryFn
parameter. For the query key, we'll use a combination of realEstateProperties
and the record's unique identifier.
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; }, });}
Optimistically render updates for a record
To optimistically render Amplify Data updates for a single record, use the TanStack useMutation
hook, passing in the update mutation as the mutationFn
parameter. We'll use the same query key combination used by the single record useQuery
hook (realEstateProperties
and the record's id
) as the query key to optimistically render the updates.
We'll use the onMutate
function to update the cache directly, as well as the onError
function to rollback changes when a request fails.
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; }, // When mutate is called: onMutate: async (newRealEstateProperty: { id: string, name?: string, address?: string }) => { // Cancel any outgoing refetches // (so they don't overwrite our optimistic update) await queryClient.cancelQueries({ queryKey: ["realEstateProperties", newRealEstateProperty.id], });
await queryClient.cancelQueries({ queryKey: ["realEstateProperties"], });
// Snapshot the previous value const previousRealEstateProperty = queryClient.getQueryData([ "realEstateProperties", newRealEstateProperty.id, ]);
// Optimistically update to the new value if (previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", newRealEstateProperty.id], /** * `newRealEstateProperty` will at first only include updated values for * the record. To avoid only rendering optimistic values for updated * fields on the UI, include the previous values for all fields: */ { ...previousRealEstateProperty, ...newRealEstateProperty } ); }
// Return a context with the previous and new realEstateProperty return { previousRealEstateProperty, newRealEstateProperty }; }, // If the mutation fails, use the context we returned above onError: (err, newRealEstateProperty, context) => { console.error("Error updating record:", err, newRealEstateProperty); if (context?.previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", context.newRealEstateProperty.id], context.previousRealEstateProperty ); } }, // Always refetch after error or success: onSettled: (newRealEstateProperty) => { if (newRealEstateProperty) { queryClient.invalidateQueries({ queryKey: ["realEstateProperties", newRealEstateProperty.id], }); queryClient.invalidateQueries({ queryKey: ["realEstateProperties"], }); } }, });}
Optimistically render deleting a record
To optimistically render a deletion of a single record, use the TanStack useMutation
hook, passing in the delete mutation as the mutationFn
parameter. We'll use the same query key combination used by the single record useQuery
hook (realEstateProperties
and the record's id
) as the query key to optimistically render the updates.
We'll use the onMutate
function to update the cache directly, as well as the onError
function to rollback changes when a delete fails.
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; }, // When mutate is called: onMutate: async (newRealEstateProperty) => { // Cancel any outgoing refetches // (so they don't overwrite our optimistic update) await queryClient.cancelQueries({ queryKey: ["realEstateProperties", newRealEstateProperty.id], });
await queryClient.cancelQueries({ queryKey: ["realEstateProperties"], });
// Snapshot the previous value const previousRealEstateProperty = queryClient.getQueryData([ "realEstateProperties", newRealEstateProperty.id, ]);
// Optimistically update to the new value if (previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", newRealEstateProperty.id], newRealEstateProperty ); }
// Return a context with the previous and new realEstateProperty return { previousRealEstateProperty, newRealEstateProperty }; }, // If the mutation fails, use the context we returned above onError: (err, newRealEstateProperty, context) => { console.error("Error deleting record:", err, newRealEstateProperty); if (context?.previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", context.newRealEstateProperty.id], context.previousRealEstateProperty ); } }, // Always refetch after error or success: onSettled: (newRealEstateProperty) => { if (newRealEstateProperty) { queryClient.invalidateQueries({ queryKey: ["realEstateProperties", newRealEstateProperty.id], }); queryClient.invalidateQueries({ queryKey: ["realEstateProperties"], }); } }, });}
Loading and error states for optimistically rendered data
Both useQuery
and useMutation
return isLoading
and isError
states that indicate the current state of the query or mutation. You can use these states to render loading and error indicators.
In addition to operation-specific loading states, TanStack Query provides a useIsFetching
hook. For the purposes of this demo, we show a global loading indicator in the Complete Example when any queries are fetching (including in the background) in order to help visualize what TanStack is doing in the background:
function GlobalLoadingIndicator() { const isFetching = useIsFetching(); return isFetching ? <div style={styles.globalLoadingIndicator}></div> : null;}
For more details on advanced usage of TanStack Query hooks, see the TanStack documentation.
The following example demonstrates how to use the state returned by TanStack to render a loading indicator while a mutation is in progress, and an error message if the mutation fails. For additional examples, see the Complete Example below.
<> {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></>
Complete example
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; }, // When mutate is called: onMutate: async (newRealEstateProperty) => { // Cancel any outgoing refetches // (so they don't overwrite our optimistic update) await queryClient.cancelQueries({ queryKey: ["realEstateProperties"] });
// Snapshot the previous value const previousRealEstateProperties = queryClient.getQueryData([ "realEstateProperties", ]);
// Optimistically update to the new value if (previousRealEstateProperties) { queryClient.setQueryData(["realEstateProperties"], (old: Schema["RealEstateProperty"]["type"][]) => [ ...old, newRealEstateProperty, ]); }
// Return a context object with the snapshotted value return { previousRealEstateProperties }; }, // If the mutation fails, // use the context returned from onMutate to rollback onError: (err, newRealEstateProperty, context) => { console.error("Error saving record:", err, newRealEstateProperty); if (context?.previousRealEstateProperties) { queryClient.setQueryData( ["realEstateProperties"], context.previousRealEstateProperties ); } }, // Always refetch after error or success: 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; }, // When mutate is called: onMutate: async (newRealEstateProperty: { id: string, name?: string, address?: string }) => { // Cancel any outgoing refetches // (so they don't overwrite our optimistic update) await queryClient.cancelQueries({ queryKey: ["realEstateProperties", newRealEstateProperty.id], });
await queryClient.cancelQueries({ queryKey: ["realEstateProperties"], });
// Snapshot the previous value const previousRealEstateProperty = queryClient.getQueryData([ "realEstateProperties", newRealEstateProperty.id, ]);
// Optimistically update to the new value if (previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", newRealEstateProperty.id], /** * `newRealEstateProperty` will at first only include updated values for * the record. To avoid only rendering optimistic values for updated * fields on the UI, include the previous values for all fields: */ { ...previousRealEstateProperty, ...newRealEstateProperty } ); }
// Return a context with the previous and new realEstateProperty return { previousRealEstateProperty, newRealEstateProperty }; }, // If the mutation fails, use the context we returned above onError: (err, newRealEstateProperty, context) => { console.error("Error updating record:", err, newRealEstateProperty); if (context?.previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", context.newRealEstateProperty.id], context.previousRealEstateProperty ); } }, // Always refetch after error or success: 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; }, // When mutate is called: onMutate: async (newRealEstateProperty) => { // Cancel any outgoing refetches // (so they don't overwrite our optimistic update) await queryClient.cancelQueries({ queryKey: ["realEstateProperties", newRealEstateProperty.id], });
await queryClient.cancelQueries({ queryKey: ["realEstateProperties"], });
// Snapshot the previous value const previousRealEstateProperty = queryClient.getQueryData([ "realEstateProperties", newRealEstateProperty.id, ]);
// Optimistically update to the new value if (previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", newRealEstateProperty.id], newRealEstateProperty ); }
// Return a context with the previous and new realEstateProperty return { previousRealEstateProperty, newRealEstateProperty }; }, // If the mutation fails, use the context we returned above onError: (err, newRealEstateProperty, context) => { console.error("Error deleting record:", err, newRealEstateProperty); if (context?.previousRealEstateProperty) { queryClient.setQueryData( ["realEstateProperties", context.newRealEstateProperty.id], context.previousRealEstateProperty ); } }, // Always refetch after error or success: 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;