Amplify has re-imagined the way frontend developers build fullstack applications. Develop and deploy without the hassle.

Page updated Apr 29, 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:

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

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.

import Amplify
import SwiftUI
import 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 SwiftUI
import Amplify
import AWSAPIPlugin
@main
struct 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()
}
}