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.
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: String5}
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
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 Amplify2import SwiftUI3import Combine4
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 return23 }24 properties = propertyList.elements25 }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.publisher9 .receive(on: DispatchQueue.main)10 .sink { properties in11 print("Updating property list")12 self.properties = properties13 }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 record9 let result = try await Amplify.API.mutate(request: .create(property))10 guard case .failure(let graphQLResponse) = result else {11 return12 }13 print("Failed with error: ", graphQLResponse)14 // Remove the newly created property15 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 property21 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 return5 }6
7 // Optimistically update the property, for the UI to render.8 let rollbackProperty = properties[index]9 properties[index] = property10 11 do {12 // Update the property record13 let result = try await Amplify.API.mutate(request: .update(property))14 guard case .failure(let graphQLResponse) = result else {15 return16 }17 print("Failed with error: ", graphQLResponse)18 properties[index] = rollbackProperty19 } catch {20 print("Failed with error: ", error)21 properties[index] = rollbackProperty22 }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 return5 }6 7 // Optimistically remove the property, for the UI to render.8 let rollbackProperty = properties[index]9 properties[index] = nil10 11 do {12 // Delete the property record13 let result = try await Amplify.API.mutate(request: .delete(property))14 switch result {15 case .success:16 // Finalize the removal17 properties.remove(at: index)18 case .failure(let graphQLResponse):19 print("Failed with error: ", graphQLResponse)20 // Undo the removal21 properties[index] = rollbackProperty22 }23 24 } catch {25 print("Failed with error: ", error)26 // Undo the removal27 properties[index] = rollbackProperty28 }29}
Complete example
1import SwiftUI2import Amplify3import AWSAPIPlugin4
5@main6struct OptimisticUIApp: App {7 8 init() {9 do {10 Amplify.Logging.logLevel = .verbose11 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.label32 .padding(10)33 .background(configuration.isPressed ? Color.teal.opacity(0.8) : Color.teal)34 .foregroundColor(.white)35 .clipShape(RoundedRectangle(cornerRadius: 10))36 }37}