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:
amplify add api
Choose the following when prompted:
? Select from one of the below mentioned services: `GraphQL`? 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)`? Choose the default authorization type for the API `API key`✔ Enter a description for the API key: · ✔ After how many days from now the API key should expire (1-365): · `365`? Configure additional auth types? `No`? Here is the GraphQL API that we will create. Select a setting to edit or continue `Continue`? 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:
type RealEstateProperty @model @auth(rules: [{ allow: public }]) { id: ID! name: String! address: String}
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.
import Amplifyimport SwiftUIimport Combine
actor RealEstatePropertyList { private var properties: [RealEstateProperty?] = [] { didSet { subject.send(properties.compactMap { $0 }) } } private let subject = PassthroughSubject<[RealEstateProperty], Never>() var publisher: AnyPublisher<[RealEstateProperty], Never> { subject.eraseToAnyPublisher() } func listProperties() async throws { let result = try await Amplify.API.query(request: .list(RealEstateProperty.self)) guard case .success(let propertyList) = result else { print("Failed with error: ", result) return } properties = propertyList.elements }}
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:
class RealEstatePropertyContainerViewModel: ObservableObject { @Published var properties: [RealEstateProperty] = [] var sink: AnyCancellable? var propertyList = RealEstatePropertyList() init() { Task { sink = await propertyList.publisher .receive(on: DispatchQueue.main) .sink { properties in print("Updating property list") self.properties = properties } } } func loadList() { Task { try? await propertyList.listProperties() } }}
struct RealEstatePropertyContainerView: View { @StateObject var vm = RealEstatePropertyContainerViewModel() @State private var propertyName: String = "" var body: some View { Text("Hello") }}
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
:
func createProperty(name: String, address: String? = nil) { let property = RealEstateProperty(name: name, address: address) // Optimistically send the newly created property, for the UI to render. properties.append(property)
Task { do { // Create the property record let result = try await Amplify.API.mutate(request: .create(property)) guard case .failure(let graphQLResponse) = result else { return } print("Failed with error: ", graphQLResponse) // Remove the newly created property if let index = properties.firstIndex(where: { $0?.id == property.id }) { properties.remove(at: index) } } catch { print("Failed with error: ", error) // Remove the newly created property if let index = properties.firstIndex(where: { $0?.id == property.id }) { properties.remove(at: index) } } }}
Optimistically rendering a record update
To optimistically render updates on a single item, use the code snippet like below:
func updateProperty(_ property: RealEstateProperty) async { guard let index = properties.firstIndex(where: { $0?.id == property.id }) else { print("No property to update") return }
// Optimistically update the property, for the UI to render. let rollbackProperty = properties[index] properties[index] = property do { // Update the property record let result = try await Amplify.API.mutate(request: .update(property)) guard case .failure(let graphQLResponse) = result else { return } print("Failed with error: ", graphQLResponse) properties[index] = rollbackProperty } catch { print("Failed with error: ", error) properties[index] = rollbackProperty }}
Optimistically render deleting a record
To optimistically render a GraphQL API delete, use the code snippet like below:
func deleteProperty(_ property: RealEstateProperty) async { guard let index = properties.firstIndex(where: { $0?.id == property.id }) else { print("No property to remove") return } // Optimistically remove the property, for the UI to render. let rollbackProperty = properties[index] properties[index] = nil do { // Delete the property record let result = try await Amplify.API.mutate(request: .delete(property)) switch result { case .success: // Finalize the removal properties.remove(at: index) case .failure(let graphQLResponse): print("Failed with error: ", graphQLResponse) // Undo the removal properties[index] = rollbackProperty } } catch { print("Failed with error: ", error) // Undo the removal properties[index] = rollbackProperty }}
Complete example
import SwiftUIimport Amplifyimport AWSAPIPlugin
@mainstruct OptimisticUIApp: App { init() { do { Amplify.Logging.logLevel = .verbose try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: AmplifyModels())) try Amplify.configure() print("Amplify configured with API, Storage, and Auth plugins!") } catch { print("Failed to initialize Amplify with \(error)") } } var body: some Scene { WindowGroup { RealEstatePropertyContainerView() } }}
// Extend the model to Identifiable to make it compatible with SwiftUI's `ForEach`.extension RealEstateProperty: Identifiable { }
struct TappedButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .padding(10) .background(configuration.isPressed ? Color.teal.opacity(0.8) : Color.teal) .foregroundColor(.white) .clipShape(RoundedRectangle(cornerRadius: 10)) }}
actor RealEstatePropertyList { private var properties: [RealEstateProperty?] = [] { didSet { subject.send(properties.compactMap { $0 }) } } private let subject = PassthroughSubject<[RealEstateProperty], Never>() var publisher: AnyPublisher<[RealEstateProperty], Never> { subject.eraseToAnyPublisher() } func listProperties() async throws { let result = try await Amplify.API.query(request: .list(RealEstateProperty.self)) guard case .success(let propertyList) = result else { print("Failed with error: ", result) return } properties = propertyList.elements } func createProperty(name: String, address: String? = nil) { let property = RealEstateProperty(name: name, address: address) // Optimistically send the newly created property, for the UI to render. properties.append(property) Task { do { // Create the property record let result = try await Amplify.API.mutate(request: .create(property)) guard case .failure(let graphQLResponse) = result else { return } print("Failed with error: ", graphQLResponse) // Remove the newly created property if let index = properties.firstIndex(where: { $0?.id == property.id }) { properties.remove(at: index) } } catch { print("Failed with error: ", error) // Remove the newly created property if let index = properties.firstIndex(where: { $0?.id == property.id }) { properties.remove(at: index) } } } }
func updateProperty(_ property: RealEstateProperty) async { guard let index = properties.firstIndex(where: { $0?.id == property.id }) else { print("No property to update") return }
// Optimistically update the property, for the UI to render. let rollbackProperty = properties[index] properties[index] = property do { // Update the property record let result = try await Amplify.API.mutate(request: .update(property)) guard case .failure(let graphQLResponse) = result else { return } print("Failed with error: ", graphQLResponse) properties[index] = rollbackProperty } catch { print("Failed with error: ", error) properties[index] = rollbackProperty } } func deleteProperty(_ property: RealEstateProperty) async { guard let index = properties.firstIndex(where: { $0?.id == property.id }) else { print("No property to remove") return } // Optimistically remove the property, for the UI to render. let rollbackProperty = properties[index] properties[index] = nil do { // Delete the property record let result = try await Amplify.API.mutate(request: .delete(property)) switch result { case .success: // Finalize the removal properties.remove(at: index) case .failure(let graphQLResponse): print("Failed with error: ", graphQLResponse) // Undo the removal properties[index] = rollbackProperty } } catch { print("Failed with error: ", error) // Undo the removal properties[index] = rollbackProperty } }}
class RealEstatePropertyContainerViewModel: ObservableObject { @Published var properties: [RealEstateProperty] = [] var sink: AnyCancellable? var propertyList = RealEstatePropertyList() init() { Task { sink = await propertyList.publisher .receive(on: DispatchQueue.main) .sink { properties in print("Updating property list") self.properties = properties } } } func loadList() { Task { try? await propertyList.listProperties() } } func createPropertyButtonTapped(name: String) { Task { await propertyList.createProperty(name: name) } } func updatePropertyButtonTapped(_ property: RealEstateProperty) { Task { await propertyList.updateProperty(property) } }
func deletePropertyButtonTapped(_ property: RealEstateProperty) { Task { await propertyList.deleteProperty(property) } }}
struct RealEstatePropertyContainerView: View { @StateObject var viewModel = RealEstatePropertyContainerViewModel() @State private var propertyName: String = "" var body: some View { VStack { ScrollView { LazyVStack(alignment: .leading) { ForEach($viewModel.properties) { $property in HStack { TextField("Update property name", text: $property.name) .textFieldStyle(RoundedBorderTextFieldStyle()) .multilineTextAlignment(.center) Button("Update") { viewModel.updatePropertyButtonTapped(property) } Button { viewModel.deletePropertyButtonTapped(property) } label: { Image(systemName: "xmark") .foregroundColor(.red) } }.padding(.horizontal) } } }.refreshable { viewModel.loadList() } TextField("New property name", text: $propertyName) .textFieldStyle(RoundedBorderTextFieldStyle()) .multilineTextAlignment(.center) Button("Save") { viewModel.createPropertyButtonTapped(name: propertyName) self.propertyName = "" } .buttonStyle(TappedButtonStyle()) }.task { viewModel.loadList() } }}
struct RealEstatePropertyContainerView_Previews: PreviewProvider { static var previews: some View { RealEstatePropertyContainerView() }}