Page updated Jan 16, 2024

Optimistic UI

Implementing optimistic UI with GraphQL API category allows CRUD operations to be rendered immediately on the UI before the request roundtrip has completed, and allows you to rollback changes on the UI when API calls are unsuccessful.

In the following example, we'll create a list view that optimistically renders newly created items, updates and deletes.

For more on the Amplify GraphQL API, see the API documentation.

To get started, go to your project directory and run the command:

1amplify add api

Choose the following when prompted:

1? Select from one of the below mentioned services: `GraphQL`
2? Here is the GraphQL API that we will create. Select a setting to edit or continue Authorization modes: `API key (default, expiration time: 7 days from now)`
3? Choose the default authorization type for the API `API key`
4✔ Enter a description for the API key: ·
5✔ After how many days from now the API key should expire (1-365): · `365`
6? Configure additional auth types? `No`
7? Here is the GraphQL API that we will create. Select a setting to edit or continue `Continue`
8? Choose a schema template: `Blank Schema`

The schema file can also be found under amplify/backend/api/[name of project]/schema.graphql. Replace it with the following contents:

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

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.

Once the backend has been provisioned, run amplify codegen models to generate the Swift model types for the app.

Next, add the Amplify(https://github.com/aws-amplify/amplify-swift.git) package to your Xcode project and select the following modules to import when prompted:

  • AWSAPIPlugin
  • AWSCognitoAuthPlugin
  • AWSS3StoragePlugin
  • Amplify

For the complete working example see the Complete Example below.

How to use a Swift Actor to perform optimistic UI updates

A Swift actor serializes access to its underlying properties. In this example, the actor will hold a list of items that will be published to the UI through a Combine publisher whenever the list is accessed. On a high level, the methods on the actor will perform the following:

  • create a new model, add it to the list, remove the newly added item from the list if the API request is unsuccessful
  • update the existing model in the list, revert the update on the list if the API request is unsuccessful
  • delete the existing model from the list, add the item back into the list if the API request is unsuccessful

By providing these methods through an actor object, the underlying list will be accessed serially so that the entire operation can be rolled back if needed.

To create an actor object that allows optimistic UI updates, create a new file and add the following code.

1import Amplify
2import SwiftUI
3import Combine
4
5actor RealEstatePropertyList {
6
7 private var properties: [RealEstateProperty?] = [] {
8 didSet {
9 subject.send(properties.compactMap { $0 })
10 }
11 }
12
13 private let subject = PassthroughSubject<[RealEstateProperty], Never>()
14 var publisher: AnyPublisher<[RealEstateProperty], Never> {
15 subject.eraseToAnyPublisher()
16 }
17
18 func listProperties() async throws {
19 let result = try await Amplify.API.query(request: .list(RealEstateProperty.self))
20 guard case .success(let propertyList) = result else {
21 print("Failed with error: ", result)
22 return
23 }
24 properties = propertyList.elements
25 }
26}

Calling the listProperties() method will perform a query with GraphQL API and store the results in the properties property. When this property is set, the list is sent back to the subscribers. In your UI, create a view model and subscribe to updates:

1class RealEstatePropertyContainerViewModel: ObservableObject {
2 @Published var properties: [RealEstateProperty] = []
3 var sink: AnyCancellable?
4
5 var propertyList = RealEstatePropertyList()
6 init() {
7 Task {
8 sink = await propertyList.publisher
9 .receive(on: DispatchQueue.main)
10 .sink { properties in
11 print("Updating property list")
12 self.properties = properties
13 }
14 }
15 }
16
17 func loadList() {
18 Task {
19 try? await propertyList.listProperties()
20 }
21 }
22}
23
24struct RealEstatePropertyContainerView: View {
25 @StateObject var vm = RealEstatePropertyContainerViewModel()
26 @State private var propertyName: String = ""
27
28 var body: some View {
29 Text("Hello")
30 }
31}

Optimistically rendering a newly created record

To optimistically render a newly created record returned from the GraphQL API, add a method to the actor RealEstatePropertyList:

1func createProperty(name: String, address: String? = nil) {
2 let property = RealEstateProperty(name: name, address: address)
3 // Optimistically send the newly created property, for the UI to render.
4 properties.append(property)
5
6 Task {
7 do {
8 // Create the property record
9 let result = try await Amplify.API.mutate(request: .create(property))
10 guard case .failure(let graphQLResponse) = result else {
11 return
12 }
13 print("Failed with error: ", graphQLResponse)
14 // Remove the newly created property
15 if let index = properties.firstIndex(where: { $0?.id == property.id }) {
16 properties.remove(at: index)
17 }
18 } catch {
19 print("Failed with error: ", error)
20 // Remove the newly created property
21 if let index = properties.firstIndex(where: { $0?.id == property.id }) {
22 properties.remove(at: index)
23 }
24 }
25 }
26}

Optimistically rendering a record update

To optimistically render updates on a single item, use the code snippet like below:

1func updateProperty(_ property: RealEstateProperty) async {
2 guard let index = properties.firstIndex(where: { $0?.id == property.id }) else {
3 print("No property to update")
4 return
5 }
6
7 // Optimistically update the property, for the UI to render.
8 let rollbackProperty = properties[index]
9 properties[index] = property
10
11 do {
12 // Update the property record
13 let result = try await Amplify.API.mutate(request: .update(property))
14 guard case .failure(let graphQLResponse) = result else {
15 return
16 }
17 print("Failed with error: ", graphQLResponse)
18 properties[index] = rollbackProperty
19 } catch {
20 print("Failed with error: ", error)
21 properties[index] = rollbackProperty
22 }
23}

Optimistically render deleting a record

To optimistically render a GraphQL API delete, use the code snippet like below:

1func deleteProperty(_ property: RealEstateProperty) async {
2 guard let index = properties.firstIndex(where: { $0?.id == property.id }) else {
3 print("No property to remove")
4 return
5 }
6
7 // Optimistically remove the property, for the UI to render.
8 let rollbackProperty = properties[index]
9 properties[index] = nil
10
11 do {
12 // Delete the property record
13 let result = try await Amplify.API.mutate(request: .delete(property))
14 switch result {
15 case .success:
16 // Finalize the removal
17 properties.remove(at: index)
18 case .failure(let graphQLResponse):
19 print("Failed with error: ", graphQLResponse)
20 // Undo the removal
21 properties[index] = rollbackProperty
22 }
23
24 } catch {
25 print("Failed with error: ", error)
26 // Undo the removal
27 properties[index] = rollbackProperty
28 }
29}

Complete example

1import SwiftUI
2import Amplify
3import AWSAPIPlugin
4
5@main
6struct OptimisticUIApp: App {
7
8 init() {
9 do {
10 Amplify.Logging.logLevel = .verbose
11 try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: AmplifyModels()))
12 try Amplify.configure()
13 print("Amplify configured with API, Storage, and Auth plugins!")
14 } catch {
15 print("Failed to initialize Amplify with \(error)")
16 }
17 }
18
19 var body: some Scene {
20 WindowGroup {
21 RealEstatePropertyContainerView()
22 }
23 }
24}
25
26// Extend the model to Identifiable to make it compatible with SwiftUI's `ForEach`.
27extension RealEstateProperty: Identifiable { }
28
29struct TappedButtonStyle: ButtonStyle {
30 func makeBody(configuration: Configuration) -> some View {
31 configuration.label
32 .padding(10)
33 .background(configuration.isPressed ? Color.teal.opacity(0.8) : Color.teal)
34 .foregroundColor(.white)
35 .clipShape(RoundedRectangle(cornerRadius: 10))
36 }
37}
1actor RealEstatePropertyList {
2
3 private var properties: [RealEstateProperty?] = [] {
4 didSet {
5 subject.send(properties.compactMap { $0 })
6 }
7 }
8
9 private let subject = PassthroughSubject<[RealEstateProperty], Never>()
10 var publisher: AnyPublisher<[RealEstateProperty], Never> {
11 subject.eraseToAnyPublisher()
12 }
13
14 func listProperties() async throws {
15 let result = try await Amplify.API.query(request: .list(RealEstateProperty.self))
16 guard case .success(let propertyList) = result else {
17 print("Failed with error: ", result)
18 return
19 }
20 properties = propertyList.elements
21 }
22
23 func createProperty(name: String, address: String? = nil) {
24 let property = RealEstateProperty(name: name, address: address)
25 // Optimistically send the newly created property, for the UI to render.
26 properties.append(property)
27
28 Task {
29 do {
30 // Create the property record
31 let result = try await Amplify.API.mutate(request: .create(property))
32 guard case .failure(let graphQLResponse) = result else {
33 return
34 }
35 print("Failed with error: ", graphQLResponse)
36 // Remove the newly created property
37 if let index = properties.firstIndex(where: { $0?.id == property.id }) {
38 properties.remove(at: index)
39 }
40 } catch {
41 print("Failed with error: ", error)
42 // Remove the newly created property
43 if let index = properties.firstIndex(where: { $0?.id == property.id }) {
44 properties.remove(at: index)
45 }
46 }
47 }
48 }
49
50 func updateProperty(_ property: RealEstateProperty) async {
51 guard let index = properties.firstIndex(where: { $0?.id == property.id }) else {
52 print("No property to update")
53 return
54 }
55
56 // Optimistically update the property, for the UI to render.
57 let rollbackProperty = properties[index]
58 properties[index] = property
59
60 do {
61 // Update the property record
62 let result = try await Amplify.API.mutate(request: .update(property))
63 guard case .failure(let graphQLResponse) = result else {
64 return
65 }
66 print("Failed with error: ", graphQLResponse)
67 properties[index] = rollbackProperty
68 } catch {
69 print("Failed with error: ", error)
70 properties[index] = rollbackProperty
71 }
72 }
73
74 func deleteProperty(_ property: RealEstateProperty) async {
75 guard let index = properties.firstIndex(where: { $0?.id == property.id }) else {
76 print("No property to remove")
77 return
78 }
79
80 // Optimistically remove the property, for the UI to render.
81 let rollbackProperty = properties[index]
82 properties[index] = nil
83
84 do {
85 // Delete the property record
86 let result = try await Amplify.API.mutate(request: .delete(property))
87 switch result {
88 case .success:
89 // Finalize the removal
90 properties.remove(at: index)
91 case .failure(let graphQLResponse):
92 print("Failed with error: ", graphQLResponse)
93 // Undo the removal
94 properties[index] = rollbackProperty
95 }
96
97 } catch {
98 print("Failed with error: ", error)
99 // Undo the removal
100 properties[index] = rollbackProperty
101 }
102 }
103}
1class RealEstatePropertyContainerViewModel: ObservableObject {
2 @Published var properties: [RealEstateProperty] = []
3 var sink: AnyCancellable?
4
5 var propertyList = RealEstatePropertyList()
6 init() {
7 Task {
8 sink = await propertyList.publisher
9 .receive(on: DispatchQueue.main)
10 .sink { properties in
11 print("Updating property list")
12 self.properties = properties
13 }
14 }
15 }
16
17 func loadList() {
18 Task {
19 try? await propertyList.listProperties()
20 }
21 }
22 func createPropertyButtonTapped(name: String) {
23 Task {
24 await propertyList.createProperty(name: name)
25 }
26 }
27
28 func updatePropertyButtonTapped(_ property: RealEstateProperty) {
29 Task {
30 await propertyList.updateProperty(property)
31 }
32 }
33
34 func deletePropertyButtonTapped(_ property: RealEstateProperty) {
35 Task {
36 await propertyList.deleteProperty(property)
37 }
38 }
39}
40
41struct RealEstatePropertyContainerView: View {
42 @StateObject var viewModel = RealEstatePropertyContainerViewModel()
43 @State private var propertyName: String = ""
44
45 var body: some View {
46 VStack {
47 ScrollView {
48 LazyVStack(alignment: .leading) {
49 ForEach($viewModel.properties) { $property in
50 HStack {
51 TextField("Update property name", text: $property.name)
52 .textFieldStyle(RoundedBorderTextFieldStyle())
53 .multilineTextAlignment(.center)
54 Button("Update") {
55 viewModel.updatePropertyButtonTapped(property)
56 }
57 Button {
58 viewModel.deletePropertyButtonTapped(property)
59 } label: {
60 Image(systemName: "xmark")
61 .foregroundColor(.red)
62 }
63
64 }.padding(.horizontal)
65 }
66 }
67 }.refreshable {
68 viewModel.loadList()
69 }
70 TextField("New property name", text: $propertyName)
71 .textFieldStyle(RoundedBorderTextFieldStyle())
72 .multilineTextAlignment(.center)
73
74 Button("Save") {
75 viewModel.createPropertyButtonTapped(name: propertyName)
76 self.propertyName = ""
77 }
78 .buttonStyle(TappedButtonStyle())
79 }.task {
80 viewModel.loadList()
81 }
82 }
83}
84
85struct RealEstatePropertyContainerView_Previews: PreviewProvider {
86 static var previews: some View {
87 RealEstatePropertyContainerView()
88 }
89}