Page updated Jan 16, 2024

Optimistic UI

The GraphQL API category 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 the GraphQL API 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.

For more details on TanStack Query, including requirements, supported browsers, and advanced usage, see the TanStack Query documentation. For complete guidance on how to implement optimistic updates with TanStack Query, see the TanStack Query Optimistic UI Documentation. For more on the Amplify GraphQL API, see the API documentation.

To get started, run the following command in an existing Amplify project with a React frontend:

1# Install TanStack Query
2npm i @tanstack/react-query
3
4# Select default configuration
5amplify add api

When prompted, use the following schema:

1type RealEstateProperty @model @auth(rules: [{ allow: public }]) {
2 id: ID!
3 name: String!
4 address: String
5}

The schema file can also be found under amplify/backend/api/[name of project]/schema.graphql. Save the schema and run amplify push to deploy the changes. 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:

1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
3
4// Create a client
5const queryClient = new QueryClient()

Next, wrap your app in the client provider:

1<QueryClientProvider client={queryClient}>
2 <App />
3 <ReactQueryDevtools initialIsOpen={false} />
4</QueryClientProvider>

TanStack Query Devtools are not required, but are a useful resource for debugging and understanding how TanStack works under the hood. By default, React Query Devtools are only included in bundles when process.env.NODE_ENV === 'development', meaning that no additional configuration is required to exclude them from a production build. For more information on the TanStack Query Devtools, visit the TanStack Query Devtools docs

For the complete working example, including required imports and React component state management, see the Complete Example below.

How to use TanStack Query query keys with the GraphQL 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 the GraphQL API, it is important to note that different query keys must be used 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 GraphQL API, use the TanStack useQuery hook, passing in the GraphQL 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.

1import { generateClient } from 'aws-amplify/api'
2
3const client = generateClient()
4
5const {
6 data: realEstateProperties,
7 isLoading,
8 isSuccess,
9 isError: isErrorQuery,
10} = useQuery({
11 queryKey: ["realEstateProperties"],
12 queryFn: async () => {
13 const response = await client.graphql({
14 query: queries.listRealEstateProperties,
15 });
16
17 const allRealEstateProperties =
18 response?.data?.listRealEstateProperties?.items;
19
20 if (!allRealEstateProperties) return null;
21
22 return allRealEstateProperties;
23 },
24});

Optimistically rendering a newly created record

To optimistically render a newly created record returned from the GraphQL API, use the TanStack useMutation hook, passing in the GraphQL 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.

1import { generateClient } from 'aws-amplify/api'
2
3const client = generateClient()
4
5const createMutation = useMutation({
6 mutationFn: async (realEstatePropertyDetails: CreateRealEstatePropertyInput) => {
7 const response = await client.graphql({
8 query: mutations.createRealEstateProperty,
9 variables: { input: realEstatePropertyDetails },
10 });
11
12 const newRealEstateProperty = response?.data?.createRealEstateProperty;
13 return newRealEstateProperty;
14 },
15 // When mutate is called:
16 onMutate: async (newRealEstateProperty) => {
17 // Cancel any outgoing refetches
18 // (so they don't overwrite our optimistic update)
19 await queryClient.cancelQueries({ queryKey: ["realEstateProperties"] });
20
21 // Snapshot the previous value
22 const previousRealEstateProperties = queryClient.getQueryData([
23 "realEstateProperties",
24 ]);
25
26 // Optimistically update to the new value
27 if (previousRealEstateProperties) {
28 queryClient.setQueryData(["realEstateProperties"], (old: RealEstateProperty[]) => [
29 ...old,
30 newRealEstateProperty,
31 ]);
32 }
33
34 // Return a context object with the snapshotted value
35 return { previousRealEstateProperties };
36 },
37 // If the mutation fails,
38 // use the context returned from onMutate to rollback
39 onError: (err, newRealEstateProperty, context) => {
40 console.error("Error saving record:", err, newRealEstateProperty);
41 if (context?.previousRealEstateProperties) {
42 queryClient.setQueryData(
43 ["realEstateProperties"],
44 context.previousRealEstateProperties
45 );
46 }
47 },
48 // Always refetch after error or success:
49 onSettled: () => {
50 queryClient.invalidateQueries({ queryKey: ["realEstateProperties"] });
51 },
52});
1import { generateClient } from 'aws-amplify/api'
2
3const client = generateClient()
4
5const createMutation = useMutation({
6 mutationFn: async (realEstatePropertyDetails) => {
7 const response = await client.graphql({
8 query: mutations.createRealEstateProperty,
9 variables: { input: realEstatePropertyDetails },
10 });
11
12 const newRealEstateProperty = response?.data?.createRealEstateProperty;
13 return newRealEstateProperty;
14 },
15 // When mutate is called:
16 onMutate: async (newRealEstateProperty) => {
17 // Cancel any outgoing refetches
18 // (so they don't overwrite our optimistic update)
19 await queryClient.cancelQueries({ queryKey: ["realEstateProperties"] });
20
21 // Snapshot the previous value
22 const previousRealEstateProperties = queryClient.getQueryData([
23 "realEstateProperties",
24 ]);
25
26 // Optimistically update to the new value
27 if (previousRealEstateProperties) {
28 queryClient.setQueryData(["realEstateProperties"], (old) => [
29 ...old,
30 newRealEstateProperty,
31 ]);
32 }
33
34 // Return a context object with the snapshotted value
35 return { previousRealEstateProperties };
36 },
37 // If the mutation fails,
38 // use the context returned from onMutate to rollback
39 onError: (err, newRealEstateProperty, context) => {
40 console.error("Error saving record:", err, newRealEstateProperty);
41 if (context?.previousRealEstateProperties) {
42 queryClient.setQueryData(
43 ["realEstateProperties"],
44 context.previousRealEstateProperties
45 );
46 }
47 },
48 // Always refetch after error or success:
49 onSettled: () => {
50 queryClient.invalidateQueries({ queryKey: ["realEstateProperties"] });
51 },
52});

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 GraphQL API query as the queryFn parameter. For the query key, we'll use a combination of realEstateProperties and the record's unique identifier.

1import { generateClient } from 'aws-amplify/api'
2
3const client = generateClient()
4
5const {
6 data: realEstateProperty,
7 isLoading,
8 isSuccess,
9 isError: isErrorQuery,
10} = useQuery({
11 queryKey: ["realEstateProperties", currentRealEstatePropertyId],
12 queryFn: async () => {
13 if (!currentRealEstatePropertyId) { return }
14
15 const response = await client.graphql({
16 query: queries.getRealEstateProperty,
17 variables: { id: currentRealEstatePropertyId },
18 });
19
20 return response.data?.getRealEstateProperty;
21 },
22});

Optimistically render updates for a record

To optimistically render GraphQL API updates for a single record, use the TanStack useMutation hook, passing in the GraphQL API 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.

When directly interacting with the cache via the onMutate function, it should be noted that the newRealEstateProperty parameter only includes the fields that are being updated, until the final return from the GraphQL API returns all fields for the record. When calling setQueryData, include the previous values for all fields in addition to the newly updated fields to avoid only rendering optimistic values for updated fields on the UI.

1import { generateClient } from 'aws-amplify/api'
2
3const client = generateClient()
4
5const updateMutation = useMutation({
6 mutationFn: async (
7 realEstatePropertyDetails: UpdateRealEstatePropertyInput
8 ) => {
9 const response = await client.graphql({
10 query: mutations.updateRealEstateProperty,
11 variables: { input: realEstatePropertyDetails },
12 });
13
14 return response?.data?.updateRealEstateProperty;
15 },
16 // When mutate is called:
17 onMutate: async (newRealEstateProperty) => {
18 // Cancel any outgoing refetches
19 // (so they don't overwrite our optimistic update)
20 await queryClient.cancelQueries({
21 queryKey: ["realEstateProperties", newRealEstateProperty.id],
22 });
23
24 await queryClient.cancelQueries({
25 queryKey: ["realEstateProperties"],
26 });
27
28 // Snapshot the previous value
29 const previousRealEstateProperty = queryClient.getQueryData([
30 "realEstateProperties",
31 newRealEstateProperty.id,
32 ]);
33
34 // Optimistically update to the new value
35 if (previousRealEstateProperty) {
36 queryClient.setQueryData(
37 ["realEstateProperties", newRealEstateProperty.id],
38 /**
39 * `newRealEstateProperty` will at first only include updated values for
40 * the record. To avoid only rendering optimistic values for updated
41 * fields on the UI, include the previous values for all fields:
42 */
43 { ...previousRealEstateProperty, ...newRealEstateProperty }
44 );
45 }
46
47 // Return a context with the previous and new realEstateProperty
48 return { previousRealEstateProperty, newRealEstateProperty };
49 },
50 // If the mutation fails, use the context we returned above
51 onError: (err, newRealEstateProperty, context) => {
52 console.error("Error updating record:", err, newRealEstateProperty);
53 if (context?.previousRealEstateProperty) {
54 queryClient.setQueryData(
55 ["realEstateProperties", context.newRealEstateProperty.id],
56 context.previousRealEstateProperty
57 );
58 }
59 },
60 // Always refetch after error or success:
61 onSettled: (newRealEstateProperty) => {
62 if (newRealEstateProperty) {
63 queryClient.invalidateQueries({
64 queryKey: ["realEstateProperties", newRealEstateProperty.id],
65 });
66 queryClient.invalidateQueries({
67 queryKey: ["realEstateProperties"],
68 });
69 }
70 },
71});
1import { generateClient } from 'aws-amplify/api'
2
3const client = generateClient()
4
5const updateMutation = useMutation({
6 mutationFn: async (realEstatePropertyDetails) => {
7 const response = await client.graphql({
8 query: mutations.updateRealEstateProperty,
9 variables: { input: realEstatePropertyDetails },
10 });
11
12 return response?.data?.updateRealEstateProperty;
13 },
14 // When mutate is called:
15 onMutate: async (newRealEstateProperty) => {
16 // Cancel any outgoing refetches
17 // (so they don't overwrite our optimistic update)
18 await queryClient.cancelQueries({
19 queryKey: ["realEstateProperties", newRealEstateProperty.id],
20 });
21
22 await queryClient.cancelQueries({
23 queryKey: ["realEstateProperties"],
24 });
25
26 // Snapshot the previous value
27 const previousRealEstateProperty = queryClient.getQueryData([
28 "realEstateProperties",
29 newRealEstateProperty.id,
30 ]);
31
32 // Optimistically update to the new value
33 if (previousRealEstateProperty) {
34 queryClient.setQueryData(
35 ["realEstateProperties", newRealEstateProperty.id],
36 /**
37 * `newRealEstateProperty` will at first only include updated values for
38 * the record. To avoid only rendering optimistic values for updated
39 * fields on the UI, include the previous values for all fields:
40 */
41 { ...previousRealEstateProperty, ...newRealEstateProperty }
42 );
43 }
44
45 // Return a context with the previous and new realEstateProperty
46 return { previousRealEstateProperty, newRealEstateProperty };
47 },
48 // If the mutation fails, use the context we returned above
49 onError: (err, newRealEstateProperty, context) => {
50 console.error("Error updating record:", err, newRealEstateProperty);
51 if (context?.previousRealEstateProperty) {
52 queryClient.setQueryData(
53 ["realEstateProperties", context.newRealEstateProperty.id],
54 context.previousRealEstateProperty
55 );
56 }
57 },
58 // Always refetch after error or success:
59 onSettled: (newRealEstateProperty) => {
60 if (newRealEstateProperty) {
61 queryClient.invalidateQueries({
62 queryKey: ["realEstateProperties", newRealEstateProperty.id],
63 });
64 queryClient.invalidateQueries({
65 queryKey: ["realEstateProperties"],
66 });
67 }
68 },
69});

Optimistically render deleting a record

To optimistically render a GraphQL API delete of a single record, use the TanStack useMutation hook, passing in the GraphQL API 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.

1import { generateClient } from 'aws-amplify/api'
2
3const client = generateClient()
4
5const deleteMutation = useMutation({
6 mutationFn: async (
7 realEstatePropertyDetails: DeleteRealEstatePropertyInput
8 ) => {
9 const response = await client.graphql({
10 query: mutations.deleteRealEstateProperty,
11 variables: { input: realEstatePropertyDetails },
12 });
13
14 return response?.data?.deleteRealEstateProperty;
15 },
16 // When mutate is called:
17 onMutate: async (newRealEstateProperty) => {
18 // Cancel any outgoing refetches
19 // (so they don't overwrite our optimistic update)
20 await queryClient.cancelQueries({
21 queryKey: ["realEstateProperties", newRealEstateProperty.id],
22 });
23
24 await queryClient.cancelQueries({
25 queryKey: ["realEstateProperties"],
26 });
27
28 // Snapshot the previous value
29 const previousRealEstateProperty = queryClient.getQueryData([
30 "realEstateProperties",
31 newRealEstateProperty.id,
32 ]);
33
34 // Optimistically update to the new value
35 if (previousRealEstateProperty) {
36 queryClient.setQueryData(
37 ["realEstateProperties", newRealEstateProperty.id],
38 newRealEstateProperty
39 );
40 }
41
42 // Return a context with the previous and new realEstateProperty
43 return { previousRealEstateProperty, newRealEstateProperty };
44 },
45 // If the mutation fails, use the context we returned above
46 onError: (err, newRealEstateProperty, context) => {
47 console.error("Error deleting record:", err, newRealEstateProperty);
48 if (context?.previousRealEstateProperty) {
49 queryClient.setQueryData(
50 ["realEstateProperties", context.newRealEstateProperty.id],
51 context.previousRealEstateProperty
52 );
53 }
54 },
55 // Always refetch after error or success:
56 onSettled: (newRealEstateProperty) => {
57 if (newRealEstateProperty) {
58 queryClient.invalidateQueries({
59 queryKey: ["realEstateProperties", newRealEstateProperty.id],
60 });
61 queryClient.invalidateQueries({
62 queryKey: ["realEstateProperties"],
63 });
64 }
65 },
66});
1import { generateClient } from 'aws-amplify/api'
2
3const client = generateClient()
4
5const deleteMutation = useMutation({
6 mutationFn: async (realEstatePropertyDetails) => {
7 const response = await client.graphql({
8 query: mutations.deleteRealEstateProperty,
9 variables: { input: realEstatePropertyDetails },
10 });
11
12 return response?.data?.deleteRealEstateProperty;
13 },
14 // When mutate is called:
15 onMutate: async (newRealEstateProperty) => {
16 // Cancel any outgoing refetches
17 // (so they don't overwrite our optimistic update)
18 await queryClient.cancelQueries({
19 queryKey: ["realEstateProperties", newRealEstateProperty.id],
20 });
21
22 await queryClient.cancelQueries({
23 queryKey: ["realEstateProperties"],
24 });
25
26 // Snapshot the previous value
27 const previousRealEstateProperty = queryClient.getQueryData([
28 "realEstateProperties",
29 newRealEstateProperty.id,
30 ]);
31
32 // Optimistically update to the new value
33 if (previousRealEstateProperty) {
34 queryClient.setQueryData(
35 ["realEstateProperties", newRealEstateProperty.id],
36 newRealEstateProperty
37 );
38 }
39
40 // Return a context with the previous and new realEstateProperty
41 return { previousRealEstateProperty, newRealEstateProperty };
42 },
43 // If the mutation fails, use the context we returned above
44 onError: (err, newRealEstateProperty, context) => {
45 console.error("Error deleting record:", err, newRealEstateProperty);
46 if (context?.previousRealEstateProperty) {
47 queryClient.setQueryData(
48 ["realEstateProperties", context.newRealEstateProperty.id],
49 context.previousRealEstateProperty
50 );
51 }
52 },
53 // Always refetch after error or success:
54 onSettled: (newRealEstateProperty) => {
55 if (newRealEstateProperty) {
56 queryClient.invalidateQueries({
57 queryKey: ["realEstateProperties", newRealEstateProperty.id],
58 });
59 queryClient.invalidateQueries({
60 queryKey: ["realEstateProperties"],
61 });
62 }
63 },
64});

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:

1function GlobalLoadingIndicator() {
2 const isFetching = useIsFetching();
3 return isFetching ? <div style={styles.globalLoadingIndicator}></div> : null;
4}

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.

1<>
2 {updateMutation.isError &&
3 updateMutation.error instanceof Error ? (
4 <div>An error occurred: {updateMutation.error.message}</div>
5 ) : null}
6
7 {updateMutation.isSuccess ? (
8 <div>Real Estate Property updated!</div>
9 ) : null}
10
11 <button
12 onClick={() =>
13 updateMutation.mutate({
14 id: realEstateProperty.id,
15 address: `${Math.floor(
16 1000 + Math.random() * 9000
17 )} Main St`,
18 })
19 }
20 >
21 Update Address
22 </button>
23</>
1<>
2 {updateMutation.isError ? (
3 <div>An error occurred: {updateMutation.error.message}</div>
4 ) : null}
5
6 {updateMutation.isSuccess ? (
7 <div>Real Estate Property updated!</div>
8 ) : null}
9
10 <button
11 onClick={() =>
12 updateMutation.mutate({
13 id: realEstateProperty.id,
14 address: `${Math.floor(
15 1000 + Math.random() * 9000
16 )} Main St`,
17 })
18 }
19 >
20 Update Address
21 </button>
22</>

Complete example

1// index.tsx:
2import React from "react";
3import ReactDOM from "react-dom/client";
4import "./index.css";
5import App from "./App";
6import { Amplify } from "aws-amplify";
7import config from "./amplifyconfiguration.json";
8import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
9import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
10import "@aws-amplify/ui-react/styles.css";
11
12Amplify.configure(config);
13
14// Create a client
15const queryClient = new QueryClient();
16
17const root = ReactDOM.createRoot(
18 document.getElementById("root") as HTMLElement
19);
20
21// Provide the client to your App
22root.render(
23 <React.StrictMode>
24 <QueryClientProvider client={queryClient}>
25 <App />
26 <ReactQueryDevtools initialIsOpen={false} />
27 </QueryClientProvider>
28 </React.StrictMode>
29);
1// App.tsx:
2import { useState } from "react";
3import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
4import { useIsFetching } from "@tanstack/react-query";
5import { generateClient } from 'aws-amplify/api'
6import * as mutations from "./graphql/mutations";
7import * as queries from "./graphql/queries";
8import { CreateRealEstatePropertyInput, DeleteRealEstatePropertyInput, RealEstateProperty, UpdateRealEstatePropertyInput } from "./API";
9
10/**
11 * https://www.tanstack.com/query/v4/docs/react/guides/background-fetching-indicators#displaying-global-background-fetching-loading-state
12 * For the purposes of this demo, we show a global loading indicator when *any*
13 * queries are fetching (including in the background) in order to help visualize
14 * what TanStack is doing in the background. This example also displays
15 * indicators for individual query and mutation loading states.
16 */
17function GlobalLoadingIndicator() {
18 const isFetching = useIsFetching();
19
20 return isFetching ? <div style={styles.globalLoadingIndicator}></div> : null;
21}
22
23const client = generateClient()
24
25function App() {
26 const [currentRealEstatePropertyId, setCurrentRealEstatePropertyId] =
27 useState<string | null>(null);
28
29 // Access the client
30 const queryClient = useQueryClient();
31
32 // TanStack Query for listing all real estate properties:
33 const {
34 data: realEstateProperties,
35 isLoading,
36 isSuccess,
37 isError: isErrorQuery,
38 } = useQuery({
39 queryKey: ["realEstateProperties"],
40 queryFn: async () => {
41 const response = await client.graphql({
42 query: queries.listRealEstateProperties,
43 });
44
45 const allRealEstateProperties =
46 response?.data?.listRealEstateProperties?.items;
47
48 if (!allRealEstateProperties) return null;
49
50 return allRealEstateProperties;
51 },
52 });
53
54 // TanStack create mutation with optimistic updates
55 const createMutation = useMutation({
56 mutationFn: async (realEstatePropertyDetails: CreateRealEstatePropertyInput) => {
57 const response = await client.graphql({
58 query: mutations.createRealEstateProperty,
59 variables: { input: realEstatePropertyDetails },
60 });
61
62 const newRealEstateProperty = response?.data?.createRealEstateProperty;
63 return newRealEstateProperty;
64 },
65 // When mutate is called:
66 onMutate: async (newRealEstateProperty) => {
67 // Cancel any outgoing refetches
68 // (so they don't overwrite our optimistic update)
69 await queryClient.cancelQueries({ queryKey: ["realEstateProperties"] });
70
71 // Snapshot the previous value
72 const previousRealEstateProperties = queryClient.getQueryData([
73 "realEstateProperties",
74 ]);
75
76 // Optimistically update to the new value
77 if (previousRealEstateProperties) {
78 queryClient.setQueryData(["realEstateProperties"], (old: RealEstateProperty[]) => [
79 ...old,
80 newRealEstateProperty,
81 ]);
82 }
83
84 // Return a context object with the snapshotted value
85 return { previousRealEstateProperties };
86 },
87 // If the mutation fails,
88 // use the context returned from onMutate to rollback
89 onError: (err, newRealEstateProperty, context) => {
90 console.error("Error saving record:", err, newRealEstateProperty);
91 if (context?.previousRealEstateProperties) {
92 queryClient.setQueryData(
93 ["realEstateProperties"],
94 context.previousRealEstateProperties
95 );
96 }
97 },
98 // Always refetch after error or success:
99 onSettled: () => {
100 queryClient.invalidateQueries({ queryKey: ["realEstateProperties"] });
101 },
102 });
103
104 /**
105 * Note: this example does not return to the list view on delete in order
106 * to demonstrate the optimistic update.
107 */
108 function RealEstatePropertyDetailView() {
109 const {
110 data: realEstateProperty,
111 isLoading,
112 isSuccess,
113 isError: isErrorQuery,
114 } = useQuery({
115 queryKey: ["realEstateProperties", currentRealEstatePropertyId],
116 queryFn: async () => {
117 if (!currentRealEstatePropertyId) { return }
118
119 const response = await client.graphql({
120 query: queries.getRealEstateProperty,
121 variables: { id: currentRealEstatePropertyId },
122 });
123
124 return response.data?.getRealEstateProperty;
125 },
126 });
127
128 // TanStack update mutation with optimistic updates
129 const updateMutation = useMutation({
130 mutationFn: async (
131 realEstatePropertyDetails: UpdateRealEstatePropertyInput
132 ) => {
133 const response = await client.graphql({
134 query: mutations.updateRealEstateProperty,
135 variables: { input: realEstatePropertyDetails },
136 });
137
138 return response?.data?.updateRealEstateProperty;
139 },
140 // When mutate is called:
141 onMutate: async (newRealEstateProperty) => {
142 // Cancel any outgoing refetches
143 // (so they don't overwrite our optimistic update)
144 await queryClient.cancelQueries({
145 queryKey: ["realEstateProperties", newRealEstateProperty.id],
146 });
147
148 await queryClient.cancelQueries({
149 queryKey: ["realEstateProperties"],
150 });
151
152 // Snapshot the previous value
153 const previousRealEstateProperty = queryClient.getQueryData([
154 "realEstateProperties",
155 newRealEstateProperty.id,
156 ]);
157
158 // Optimistically update to the new value
159 if (previousRealEstateProperty) {
160 queryClient.setQueryData(
161 ["realEstateProperties", newRealEstateProperty.id],
162 /**
163 * `newRealEstateProperty` will at first only include updated values for
164 * the record. To avoid only rendering optimistic values for updated
165 * fields on the UI, include the previous values for all fields:
166 */
167 { ...previousRealEstateProperty, ...newRealEstateProperty }
168 );
169 }
170
171 // Return a context with the previous and new realEstateProperty
172 return { previousRealEstateProperty, newRealEstateProperty };
173 },
174 // If the mutation fails, use the context we returned above
175 onError: (err, newRealEstateProperty, context) => {
176 console.error("Error updating record:", err, newRealEstateProperty);
177 if (context?.previousRealEstateProperty) {
178 queryClient.setQueryData(
179 ["realEstateProperties", context.newRealEstateProperty.id],
180 context.previousRealEstateProperty
181 );
182 }
183 },
184 // Always refetch after error or success:
185 onSettled: (newRealEstateProperty) => {
186 if (newRealEstateProperty) {
187 queryClient.invalidateQueries({
188 queryKey: ["realEstateProperties", newRealEstateProperty.id],
189 });
190 queryClient.invalidateQueries({
191 queryKey: ["realEstateProperties"],
192 });
193 }
194 },
195 });
196
197 // TanStack delete mutation with optimistic updates
198 const deleteMutation = useMutation({
199 mutationFn: async (
200 realEstatePropertyDetails: DeleteRealEstatePropertyInput
201 ) => {
202 const response = await client.graphql({
203 query: mutations.deleteRealEstateProperty,
204 variables: { input: realEstatePropertyDetails },
205 });
206
207 return response?.data?.deleteRealEstateProperty;
208 },
209 // When mutate is called:
210 onMutate: async (newRealEstateProperty) => {
211 // Cancel any outgoing refetches
212 // (so they don't overwrite our optimistic update)
213 await queryClient.cancelQueries({
214 queryKey: ["realEstateProperties", newRealEstateProperty.id],
215 });
216
217 await queryClient.cancelQueries({
218 queryKey: ["realEstateProperties"],
219 });
220
221 // Snapshot the previous value
222 const previousRealEstateProperty = queryClient.getQueryData([
223 "realEstateProperties",
224 newRealEstateProperty.id,
225 ]);
226
227 // Optimistically update to the new value
228 if (previousRealEstateProperty) {
229 queryClient.setQueryData(
230 ["realEstateProperties", newRealEstateProperty.id],
231 newRealEstateProperty
232 );
233 }
234
235 // Return a context with the previous and new realEstateProperty
236 return { previousRealEstateProperty, newRealEstateProperty };
237 },
238 // If the mutation fails, use the context we returned above
239 onError: (err, newRealEstateProperty, context) => {
240 console.error("Error deleting record:", err, newRealEstateProperty);
241 if (context?.previousRealEstateProperty) {
242 queryClient.setQueryData(
243 ["realEstateProperties", context.newRealEstateProperty.id],
244 context.previousRealEstateProperty
245 );
246 }
247 },
248 // Always refetch after error or success:
249 onSettled: (newRealEstateProperty) => {
250 if (newRealEstateProperty) {
251 queryClient.invalidateQueries({
252 queryKey: ["realEstateProperties", newRealEstateProperty.id],
253 });
254 queryClient.invalidateQueries({
255 queryKey: ["realEstateProperties"],
256 });
257 }
258 },
259 });
260
261 return (
262 <div style={styles.detailViewContainer}>
263 <h2>Real Estate Property Detail View</h2>
264 {isErrorQuery && <div>{"Problem loading Real Estate Property"}</div>}
265 {isLoading && (
266 <div style={styles.loadingIndicator}>
267 {"Loading Real Estate Property..."}
268 </div>
269 )}
270 {isSuccess && (
271 <div>
272 <p>{`Name: ${realEstateProperty?.name}`}</p>
273 <p>{`Address: ${realEstateProperty?.address}`}</p>
274 </div>
275 )}
276 {realEstateProperty && (
277 <div>
278 <div>
279 {updateMutation.isPending ? (
280 "Updating Real Estate Property..."
281 ) : (
282 <>
283 {updateMutation.isError &&
284 updateMutation.error instanceof Error ? (
285 <div>An error occurred: {updateMutation.error.message}</div>
286 ) : null}
287
288 {updateMutation.isSuccess ? (
289 <div>Real Estate Property updated!</div>
290 ) : null}
291
292 <button
293 onClick={() =>
294 updateMutation.mutate({
295 id: realEstateProperty.id,
296 name: `Updated Home ${Date.now()}`,
297 })
298 }
299 >
300 Update Name
301 </button>
302 <button
303 onClick={() =>
304 updateMutation.mutate({
305 id: realEstateProperty.id,
306 address: `${Math.floor(
307 1000 + Math.random() * 9000
308 )} Main St`,
309 })
310 }
311 >
312 Update Address
313 </button>
314 </>
315 )}
316 </div>
317
318 <div>
319 {deleteMutation.isPending ? (
320 "Deleting Real Estate Property..."
321 ) : (
322 <>
323 {deleteMutation.isError &&
324 deleteMutation.error instanceof Error ? (
325 <div>An error occurred: {deleteMutation.error.message}</div>
326 ) : null}
327
328 {deleteMutation.isSuccess ? (
329 <div>Real Estate Property deleted!</div>
330 ) : null}
331
332 <button
333 onClick={() =>
334 deleteMutation.mutate({
335 id: realEstateProperty.id,
336 })
337 }
338 >
339 Delete
340 </button>
341 </>
342 )}
343 </div>
344 </div>
345 )}
346 <button onClick={() => setCurrentRealEstatePropertyId(null)}>
347 Back
348 </button>
349 </div>
350 );
351 }
352
353 return (
354 <div>
355 {!currentRealEstatePropertyId && (
356 <div style={styles.appContainer}>
357 <h1>Real Estate Properties:</h1>
358 <div>
359 {createMutation.isPending ? (
360 "Adding Real Estate Property..."
361 ) : (
362 <>
363 {createMutation.isError &&
364 createMutation.error instanceof Error ? (
365 <div>An error occurred: {createMutation.error.message}</div>
366 ) : null}
367
368 {createMutation.isSuccess ? (
369 <div>Real Estate Property added!</div>
370 ) : null}
371
372 <button
373 onClick={() => {
374 createMutation.mutate({
375 name: `New Home ${Date.now()}`,
376 address: `${Math.floor(
377 1000 + Math.random() * 9000
378 )} Main St`,
379 });
380 }}
381 >
382 Add RealEstateProperty
383 </button>
384 </>
385 )}
386 </div>
387 <ul style={styles.propertiesList}>
388 {isLoading && (
389 <div style={styles.loadingIndicator}>
390 {"Loading Real Estate Properties..."}
391 </div>
392 )}
393 {isErrorQuery && (
394 <div>{"Problem loading Real Estate Properties"}</div>
395 )}
396 {isSuccess &&
397 realEstateProperties?.map((realEstateProperty, idx) => {
398 if (!realEstateProperty) return null;
399 return (
400 <li
401 style={styles.listItem}
402 key={`${idx}-${realEstateProperty.id}`}
403 >
404 <p>{realEstateProperty.name}</p>
405 <button
406 style={styles.detailViewButton}
407 onClick={() =>
408 setCurrentRealEstatePropertyId(realEstateProperty.id)
409 }
410 >
411 Detail View
412 </button>
413 </li>
414 );
415 })}
416 </ul>
417 </div>
418 )}
419 {currentRealEstatePropertyId && <RealEstatePropertyDetailView />}
420 <GlobalLoadingIndicator />
421 </div>
422 );
423}
424
425export default App;
426
427const styles = {
428 appContainer: {
429 display: "flex",
430 flexDirection: "column",
431 alignItems: "center",
432 },
433 detailViewButton: { marginLeft: "1rem" },
434 detailViewContainer: { border: "1px solid black", padding: "3rem" },
435 globalLoadingIndicator: {
436 position: "fixed",
437 top: 0,
438 left: 0,
439 width: "100%",
440 height: "100%",
441 border: "4px solid blue",
442 pointerEvents: "none",
443 },
444 listItem: {
445 display: "flex",
446 justifyContent: "space-between",
447 border: "1px dotted grey",
448 padding: ".5rem",
449 margin: ".1rem",
450 },
451 loadingIndicator: {
452 border: "1px solid black",
453 padding: "1rem",
454 margin: "1rem",
455 },
456 propertiesList: {
457 display: "flex",
458 flexDirection: "column",
459 alignItems: "center",
460 justifyContent: "start",
461 width: "50%",
462 border: "1px solid black",
463 padding: "1rem",
464 listStyleType: "none",
465 },
466} as const;
1// index.jsx
2import React from "react";
3import ReactDOM from "react-dom/client";
4import "./index.css";
5import App from "./App";
6import { Amplify } from "aws-amplify";
7import config from "./amplifyconfiguration.json";
8import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
9import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
10import "@aws-amplify/ui-react/styles.css";
11
12Amplify.configure(config);
13
14// Create a client
15const queryClient = new QueryClient();
16
17const root = ReactDOM.createRoot(document.getElementById("root"));
18
19// Provide the client to your App
20root.render(
21 <React.StrictMode>
22 <QueryClientProvider client={queryClient}>
23 <App />
24 <ReactQueryDevtools initialIsOpen={false} />
25 </QueryClientProvider>
26 </React.StrictMode>
27);
1// App.jsx:
2import { useState } from "react";
3import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
4import { useIsFetching } from "@tanstack/react-query";
5import { generateClient } from 'aws-amplify/api'
6import * as mutations from "./graphql/mutations";
7import * as queries from "./graphql/queries";
8
9/**
10 * https://www.tanstack.com/query/v4/docs/react/guides/background-fetching-indicators#displaying-global-background-fetching-loading-state
11 * For the purposes of this demo, we show a global loading indicator when *any*
12 * queries are fetching (including in the background) in order to help visualize
13 * what TanStack is doing in the background. This example also displays
14 * indicators for individual query and mutation loading states.
15 */
16function GlobalLoadingIndicator() {
17 const isFetching = useIsFetching();
18
19 return isFetching ? <div style={styles.globalLoadingIndicator}></div> : null;
20}
21
22const client = generateClient()
23
24function App() {
25 const [currentRealEstatePropertyId, setCurrentRealEstatePropertyId] = useState(null);
26
27 // Access the client
28 const queryClient = useQueryClient();
29
30 // TanStack Query for listing all real estate properties:
31 const {
32 data: realEstateProperties,
33 isLoading,
34 isSuccess,
35 isError: isErrorQuery,
36 } = useQuery({
37 queryKey: ["realEstateProperties"],
38 queryFn: async () => {
39 const response = await client.graphql({
40 query: queries.listRealEstateProperties,
41 });
42
43 const allRealEstateProperties =
44 response?.data?.listRealEstateProperties?.items;
45
46 if (!allRealEstateProperties) return null;
47
48 return allRealEstateProperties;
49 },
50 });
51
52 // TanStack create mutation with optimistic updates
53 const createMutation = useMutation({
54 mutationFn: async (realEstatePropertyDetails) => {
55 const response = await client.graphql({
56 query: mutations.createRealEstateProperty,
57 variables: { input: realEstatePropertyDetails },
58 });
59
60 const newRealEstateProperty = response?.data?.createRealEstateProperty;
61 return newRealEstateProperty;
62 },
63 // When mutate is called:
64 onMutate: async (newRealEstateProperty) => {
65 // Cancel any outgoing refetches
66 // (so they don't overwrite our optimistic update)
67 await queryClient.cancelQueries({ queryKey: ["realEstateProperties"] });
68
69 // Snapshot the previous value
70 const previousRealEstateProperties = queryClient.getQueryData([
71 "realEstateProperties",
72 ]);
73
74 // Optimistically update to the new value
75 if (previousRealEstateProperties) {
76 queryClient.setQueryData(["realEstateProperties"], (old) => [
77 ...old,
78 newRealEstateProperty,
79 ]);
80 }
81
82 // Return a context object with the snapshotted value
83 return { previousRealEstateProperties };
84 },
85 // If the mutation fails,
86 // use the context returned from onMutate to rollback
87 onError: (err, newRealEstateProperty, context) => {
88 console.error("Error saving record:", err, newRealEstateProperty);
89 if (context?.previousRealEstateProperties) {
90 queryClient.setQueryData(
91 ["realEstateProperties"],
92 context.previousRealEstateProperties
93 );
94 }
95 },
96 // Always refetch after error or success:
97 onSettled: () => {
98 queryClient.invalidateQueries({ queryKey: ["realEstateProperties"] });
99 },
100 });
101
102 /**
103 * Note: this example does not return to the list view on delete in order
104 * to demonstrate the optimistic update.
105 */
106 function RealEstatePropertyDetailView() {
107 const {
108 data: realEstateProperty,
109 isLoading,
110 isSuccess,
111 isError: isErrorQuery,
112 } = useQuery({
113 queryKey: ["realEstateProperties", currentRealEstatePropertyId],
114 queryFn: async () => {
115 if (!currentRealEstatePropertyId) { return }
116
117 const response = await client.graphql({
118 query: queries.getRealEstateProperty,
119 variables: { id: currentRealEstatePropertyId },
120 });
121
122 return response.data?.getRealEstateProperty;
123 },
124 });
125
126 // TanStack update mutation with optimistic updates
127 const updateMutation = useMutation({
128 mutationFn: async (realEstatePropertyDetails) => {
129 const response = await client.graphql({
130 query: mutations.updateRealEstateProperty,
131 variables: { input: realEstatePropertyDetails },
132 });
133
134 return response?.data?.updateRealEstateProperty;
135 },
136 // When mutate is called:
137 onMutate: async (newRealEstateProperty) => {
138 // Cancel any outgoing refetches
139 // (so they don't overwrite our optimistic update)
140 await queryClient.cancelQueries({
141 queryKey: ["realEstateProperties", newRealEstateProperty.id],
142 });
143
144 await queryClient.cancelQueries({
145 queryKey: ["realEstateProperties"],
146 });
147
148 // Snapshot the previous value
149 const previousRealEstateProperty = queryClient.getQueryData([
150 "realEstateProperties",
151 newRealEstateProperty.id,
152 ]);
153
154 // Optimistically update to the new value
155 if (previousRealEstateProperty) {
156 queryClient.setQueryData(
157 ["realEstateProperties", newRealEstateProperty.id],
158 /**
159 * `newRealEstateProperty` will at first only include updated values for
160 * the record. To avoid only rendering optimistic values for updated
161 * fields on the UI, include the previous values for all fields:
162 */
163 { ...previousRealEstateProperty, ...newRealEstateProperty }
164 );
165 }
166
167 // Return a context with the previous and new realEstateProperty
168 return { previousRealEstateProperty, newRealEstateProperty };
169 },
170 // If the mutation fails, use the context we returned above
171 onError: (err, newRealEstateProperty, context) => {
172 console.error("Error updating record:", err, newRealEstateProperty);
173 if (context?.previousRealEstateProperty) {
174 queryClient.setQueryData(
175 ["realEstateProperties", context.newRealEstateProperty.id],
176 context.previousRealEstateProperty
177 );
178 }
179 },
180 // Always refetch after error or success:
181 onSettled: (newRealEstateProperty) => {
182 if (newRealEstateProperty) {
183 queryClient.invalidateQueries({
184 queryKey: ["realEstateProperties", newRealEstateProperty.id],
185 });
186 queryClient.invalidateQueries({
187 queryKey: ["realEstateProperties"],
188 });
189 }
190 },
191 });
192
193 // TanStack delete mutation with optimistic updates
194 const deleteMutation = useMutation({
195 mutationFn: async (realEstatePropertyDetails) => {
196 const response = await client.graphql({
197 query: mutations.deleteRealEstateProperty,
198 variables: { input: realEstatePropertyDetails },
199 });
200
201 return response?.data?.deleteRealEstateProperty;
202 },
203 // When mutate is called:
204 onMutate: async (newRealEstateProperty) => {
205 // Cancel any outgoing refetches
206 // (so they don't overwrite our optimistic update)
207 await queryClient.cancelQueries({
208 queryKey: ["realEstateProperties", newRealEstateProperty.id],
209 });
210
211 await queryClient.cancelQueries({
212 queryKey: ["realEstateProperties"],
213 });
214
215 // Snapshot the previous value
216 const previousRealEstateProperty = queryClient.getQueryData([
217 "realEstateProperties",
218 newRealEstateProperty.id,
219 ]);
220
221 // Optimistically update to the new value
222 if (previousRealEstateProperty) {
223 queryClient.setQueryData(
224 ["realEstateProperties", newRealEstateProperty.id],
225 newRealEstateProperty
226 );
227 }
228
229 // Return a context with the previous and new realEstateProperty
230 return { previousRealEstateProperty, newRealEstateProperty };
231 },
232 // If the mutation fails, use the context we returned above
233 onError: (err, newRealEstateProperty, context) => {
234 console.error("Error deleting record:", err, newRealEstateProperty);
235 if (context?.previousRealEstateProperty) {
236 queryClient.setQueryData(
237 ["realEstateProperties", context.newRealEstateProperty.id],
238 context.previousRealEstateProperty
239 );
240 }
241 },
242 // Always refetch after error or success:
243 onSettled: (newRealEstateProperty) => {
244 if (newRealEstateProperty) {
245 queryClient.invalidateQueries({
246 queryKey: ["realEstateProperties", newRealEstateProperty.id],
247 });
248 queryClient.invalidateQueries({
249 queryKey: ["realEstateProperties"],
250 });
251 }
252 },
253 });
254
255 return (
256 <div style={styles.detailViewContainer}>
257 <h2>Real Estate Property Detail View</h2>
258 {isErrorQuery && <div>{"Problem loading Real Estate Property"}</div>}
259 {isLoading && (
260 <div style={styles.loadingIndicator}>
261 {"Loading Real Estate Property..."}
262 </div>
263 )}
264 {isSuccess && (
265 <div>
266 <p>{`Name: ${realEstateProperty?.name}`}</p>
267 <p>{`Address: ${realEstateProperty?.address}`}</p>
268 </div>
269 )}
270 {realEstateProperty && (
271 <div>
272 <div>
273 {updateMutation.isPending ? (
274 "Updating Real Estate Property..."
275 ) : (
276 <>
277 {updateMutation.isError &&
278 updateMutation.error instanceof Error ? (
279 <div>An error occurred: {updateMutation.error.message}</div>
280 ) : null}
281
282 {updateMutation.isSuccess ? (
283 <div>Real Estate Property updated!</div>
284 ) : null}
285
286 <button
287 onClick={() =>
288 updateMutation.mutate({
289 id: realEstateProperty.id,
290 name: `Updated Home ${Date.now()}`,
291 })
292 }
293 >
294 Update Name
295 </button>
296 <button
297 onClick={() =>
298 updateMutation.mutate({
299 id: realEstateProperty.id,
300 address: `${Math.floor(
301 1000 + Math.random() * 9000
302 )} Main St`,
303 })
304 }
305 >
306 Update Address
307 </button>
308 </>
309 )}
310 </div>
311
312 <div>
313 {deleteMutation.isPending ? (
314 "Deleting Real Estate Property..."
315 ) : (
316 <>
317 {deleteMutation.isError &&
318 deleteMutation.error instanceof Error ? (
319 <div>An error occurred: {deleteMutation.error.message}</div>
320 ) : null}
321
322 {deleteMutation.isSuccess ? (
323 <div>Real Estate Property deleted!</div>
324 ) : null}
325
326 <button
327 onClick={() =>
328 deleteMutation.mutate({
329 id: realEstateProperty.id,
330 })
331 }
332 >
333 Delete
334 </button>
335 </>
336 )}
337 </div>
338 </div>
339 )}
340 <button onClick={() => setCurrentRealEstatePropertyId(null)}>
341 Back
342 </button>
343 </div>
344 );
345 }
346
347 return (
348 <div>
349 {!currentRealEstatePropertyId && (
350 <div style={styles.appContainer}>
351 <h1>Real Estate Properties:</h1>
352 <div>
353 {createMutation.isPending ? (
354 "Adding Real Estate Property..."
355 ) : (
356 <>
357 {createMutation.isError &&
358 createMutation.error instanceof Error ? (
359 <div>An error occurred: {createMutation.error.message}</div>
360 ) : null}
361
362 {createMutation.isSuccess ? (
363 <div>Real Estate Property added!</div>
364 ) : null}
365
366 <button
367 onClick={() => {
368 createMutation.mutate({
369 name: `New Home ${Date.now()}`,
370 address: `${Math.floor(
371 1000 + Math.random() * 9000
372 )} Main St`,
373 });
374 }}
375 >
376 Add RealEstateProperty
377 </button>
378 </>
379 )}
380 </div>
381 <ul style={styles.propertiesList}>
382 {isLoading && (
383 <div style={styles.loadingIndicator}>
384 {"Loading Real Estate Properties..."}
385 </div>
386 )}
387 {isErrorQuery && (
388 <div>{"Problem loading Real Estate Properties"}</div>
389 )}
390 {isSuccess &&
391 realEstateProperties?.map((realEstateProperty, idx) => {
392 if (!realEstateProperty) return null;
393 return (
394 <li
395 style={styles.listItem}
396 key={`${idx}-${realEstateProperty.id}`}
397 >
398 <p>{realEstateProperty.name}</p>
399 <button
400 style={styles.detailViewButton}
401 onClick={() =>
402 setCurrentRealEstatePropertyId(realEstateProperty.id)
403 }
404 >
405 Detail View
406 </button>
407 </li>
408 );
409 })}
410 </ul>
411 </div>
412 )}
413 {currentRealEstatePropertyId && <RealEstatePropertyDetailView />}
414 <GlobalLoadingIndicator />
415 </div>
416 );
417}
418
419export default App;
420
421const styles = {
422 appContainer: {
423 display: "flex",
424 flexDirection: "column",
425 alignItems: "center",
426 },
427 detailViewButton: { marginLeft: "1rem" },
428 detailViewContainer: { border: "1px solid black", padding: "3rem" },
429 globalLoadingIndicator: {
430 position: "fixed",
431 top: 0,
432 left: 0,
433 width: "100%",
434 height: "100%",
435 border: "4px solid blue",
436 pointerEvents: "none",
437 },
438 listItem: {
439 display: "flex",
440 justifyContent: "space-between",
441 border: "1px dotted grey",
442 padding: ".5rem",
443 margin: ".1rem",
444 },
445 loadingIndicator: {
446 border: "1px solid black",
447 padding: "1rem",
448 margin: "1rem",
449 },
450 propertiesList: {
451 display: "flex",
452 flexDirection: "column",
453 alignItems: "center",
454 justifyContent: "start",
455 width: "50%",
456 border: "1px solid black",
457 padding: "1rem",
458 listStyleType: "none",
459 },
460};