Reactive APIs

    • Couchbase Lite 3.2.3
      +
      The Reactive APIs are enhanced APIs for Swift that streamline data modeling and enable reactive programming patterns.

      The Reactive APIs are designed for developers building apps with SwiftUI, Combine, or Swift’s new Observation framework. They provide two tightly integrated capabilities:

      • Codable protocol compatibility — enables automatic mapping between Couchbase Lite documents and Swift model objects using the Codable protocol, reducing boilerplate and improving type safety.

      • Change publishers — provides Combine and Observation publishers for observing changes to queries, documents, and collections, enabling SwiftUI views and view models to react automatically to underlying data updates.

      Together, these APIs allow developers to define Swift classes that can be directly persisted as Couchbase Lite documents and observed for changes in a reactive UI context. They eliminate the need for manual dictionary manipulation, JSON conversion, or explicit change listeners.

      The Reactive APIs are available for Swift in Couchbase Lite 3.2.3 and later.

      Represent Documents as Codable Model Objects

      Prior to the Reactive APIs, Couchbase Lite developers working with Swift could interact with documents through APIs that require manual handling of data conversion. For example, reading and writing data would often involve using the Mutable Documents abstraction and working directly with dictionary representations. While this approach provides flexibility, it can involve additional steps for developers when integrating Swift models into their applications. For more information, see Documents.

      The Reactive APIs simplify this process by introducing native adherence to the Codable protocol. This eliminates intermediate conversion layers, reducing the need to convert from Fleece format to JSON, and then to app-specific object representation.

      Map Couchbase Lite Data to Swift Codable Document Models

      A document model is a Swift class that conforms to Codable and includes an optional property annotated with @DocumentID. This special property wrapper associates the model with a Couchbase Lite document via its unique document ID.

      If no document ID is provided, Couchbase Lite generates a new one automatically and assigns to the model when it is encoded and saved.

      Document models must be declared as classes, not structs. This ensures reference semantics and avoids issues where multiple in-memory objects point to the same document.
      Example 1. Data model
      class Task: Codable {
          @DocumentID var id: String?
          var title: String
          var completed: Bool?
      }

      Manipulate Documents as Codable Model Objects

      Once defined, document models can be persisted, retrieved, and removed using type-safe APIs on the Collection object.

      Get a Document as a Decodable Object

      To get a document from a collection and decode it into a Swift model:

      • Use the document function with the collection.

      • Specify the document ID and type.

      let document = try collection.document(id: task.id!, as: Task.self)

      For API details, see Document Management.

      Save an Encodable Object as a Document

      To save a document represented by a Swift model object into a collection:

      • Use the save function with the collection.

      • Specify the object.

      • Optionally, specify a conflict handler or a concurrency control. By default, last-write-wins concurrency control will be used if a conflict occurs.

      try collection.save(from: task)
      Specify a conflict handler
      let resolved = try collection.save(from: task) { newTask, existingTask in
          newTask.title = "New Task"
          newTask.completed = false
          return true
      }
      Specify concurrency control
      let resolved = try collection.save(from: task, concurrencyControl: .failOnConflict)

      For API details, see Document Management.

      Delete a Document

      To remove a document represented by a Swift model object from a collection:

      • Use the delete function with the collection.

      • Specify the object.

      • Optionally, specify a concurrency control. By default, last-write-wins concurrency control will be used if a conflict occurs.

      try collection.delete(for: task)
      Specify concurrency control
      let resolved = try collection.delete(for: task, concurrencyControl: .failOnConflict)

      For API details, see Document Management.

      Purge a Document

      Purging a document is permanent deletion, not replicated.

      To purge a document represented by a Swift model object from a collection:

      • Use the purge function with the collection.

      • Specify the object.

      try collection.purge(for: task)

      For API details, see Document Management.

      Decode Query Results

      The Reactive APIs enable you to decode Couchbase SQL++ query results directly into model objects. This makes queries observable, so that you can refresh the application automatically as the results change.

      With prior solutions such as Live Queries, you need to trigger user interface updates manually inside the listener. This can lead to complex and difficult-to-maintain state management. With the Reactive APIs, you can use SwiftUI’s minimalistic and declarative approach to manage querying and updating the user interface.

      To make query results observable, the model object only needs to conform to the decodable protocol, and does not require a document ID property annotated with the @DocumentID property wrapper.

      However, if you wish to save any result later, the decoding model must include a document ID property with the @DocumentID property wrapper. Additionally, the query result must include a document ID, which can be retrieved using the meta().id expression, as shown in Example 2.

      Example 2. Query results with document ID
      SELECT meta().id AS id, title FROM _default.tasks

      Get a Query Result as Decodable

      To get a query’s result as a decodable model object:

      • Use the data function with the query result.

      • Specify the decodable model type.

      • Optionally, specify a data key.

      let results = try query.execute().allResults()
      for result in results {
          task = try result.data(as: Task.self)
      }

      For API details, see ReadOnlyArrayProtocol.

      When saving a document model decoded from the query’s result, the document associated with the model will be retrieved during the save time. As a result, the document may not be the same document during the query time if the document has been changed.

      Get All Query Results as Decodable

      To get all a query’s results as an array of a decodable model objects:

      • Use the data function with the query result set.

      • Specify the decodable model type.

      • Optionally, specify a data key.

      tasks = try query.execute().data(as: Task.self)

      For API details, see ResultSet.

      Getting all the query’s results as an array of decodable model objects may take a long time and consume a large amount of memory, depending on the data size and the number of results.

      Specify a Data Key

      If the query includes a wildcard (*), you can provide an optional data key to the data function to specify the alias of the * dictionary. For instance, in Example 3, the wildcard produces a dictionary with the alias data.

      Example 3. Query with a wildcard
      SELECT meta().id AS id, * AS data FROM _default.tasks

      When using data(as:, dataKey:) to decode the query result, setting the data key to data ensures the correct source is used.

      Publish Change Notifications

      To align with declarative programming paradigms, the Reactive APIs provide an efficient way to publish change notifications. This allows for automatic updates to UI components, streamlined data pipelines, and improved code readability.

      To publish change notifications, the Reactive APIs provide interoperability with two underlying Swift frameworks. The way that you use the Reactive APIs differs slightly, depending on the underlying framework. The framework that you use depends on your operating system version.

      • The Combine framework is available in iOS 13 and later, and macOS 10.15 to 14.

      • The Observation framework is available in iOS 17 and later, and macOS 14 and later.

      Publish Query Result Changes

      To get a publisher that emits QueryChange events whenever a query result changes:

      • Use the changePublisher function with the query.

      • Optionally, specify the dispatch queue on which the changes are to be delivered. By default, the main queue is used.

      query.changePublisher()
          .map { try! $0.results?.data(as: Task.self) ?? [] }
          .sink { [weak self] tasks in
              self?.tasks = tasks
          }
          .store(in: &cancellables)

      For API details, see Query.

      The emitted QueryChange contains the entire new result set of the query, not just the delta.

      Publish Document Changes

      To get a publisher that emits CollectionChange events whenever any documents are added, removed or changed in that collection:

      • Use the changePublisher function with the collection.

      • Optionally, specify the dispatch queue on which the changes are to be delivered. By default, the main queue is used.

      collection.changePublisher()
          .sink { change in print("Collection \(change.collection.name) changed.") }
          .store(in: &cancellables)

      For API details, see Combine Publisher.

      To get a publisher that emits DocumentChange events whenever a specific document in a collection changes:

      • Use the documentChangePublisher function with the collection.

      • Specify the document ID.

      • Optionally, specify the dispatch queue on which the changes are to be delivered. By default, the main queue is used.

      collection.documentChangePublisher(for: task.id!)
          .sink { change in
              print("Task \(change.documentID) in collection \(change.collection.name) changed.")
          }
          .store(in: &cancellables)

      For API details, see Combine Publisher.

      Publish Replicator Changes

      To get a publisher that emits ReplicatorChange events when a replicator’s status changes:

      • Use the changePublisher function with the replicator.

      • Optionally, specify the dispatch queue on which the changes are to be delivered. By default, the main queue is used.

      replicator.changePublisher()
          .sink { change in print("Replicator status changed: \(change)") }
          .store(in: &cancellables)

      For API details, see Combine Publisher.

      To get a publisher that emits DocumentReplication events whenever documents are pushed or pulled during replication:

      • Use the documentReplicationPublisher function with the replicator.

      • Optionally, specify the dispatch queue on which the changes are to be delivered. By default, the main queue is used.

      replicator.documentReplicationPublisher()
          .sink { change in
              change.documents.forEach { task in
                  print("Task \(task.id) replicated.")
              }
          }
          .store(in: &cancellables)

      For API details, see Combine Publisher.

      Examples

      ViewModel

      • Combine Framework

      • Observation Framework

      For iOS 13 and later, or macOS 10.15 to 14:

      @MainActor
      class TodoViewModel: ObservableObject {
         @Published var todos: [Task] = []
      
         private let db: Database
         private let tasks: Collection
         private var cancellables = Set<AnyCancellable>()
      
         init() {
             self.db = AppDataStore.shared.database
             self.tasks = db.collection(name: "tasks")
      
      
             let query = "SELECT meta().id, title, completed from tasks"
             try? db.createQuery(query).publish()
                 .map { $0.results?.data(as: Task.self) ?? [] }
                 .sink { [weak self] tasks in self?.todos = tasks }
                 .store(in: &cancellables)
         }
      
         func addTask(title: String) throws {
             try tasks.save(from: Task(title: title))
         }
      
         func toggleCompletion(for task: Task) throws {
             if let index = todos.firstIndex(where: { $0.id == task.id }) {
                 todos[index].completed.toggle() // Reflect UI right away
                 try tasks.save(from: todos[index])
             }
         }
      
         func deleteTask(_ task: Task) throws {
             try tasks.delete(for: task)
         }
      }

      For iOS 17 and later, or macOS 14 and later:

      @Observable class TodoViewModel {
          var todos: [Task] = []    // Observable property automatically tracked by SwiftUI
      
          private let db: Database
          private let tasks: Collection
      
          init() {
              self.db = AppDataStore.shared.database
              self.tasks = db.collection(name: "tasks")
      
              let query = "SELECT meta().id, title, completed FROM tasks"
      
              // Subscribe to changes and update todos directly
              try? db.createQuery(query).publish()
                  .map { $0.results?.data(as: Task.self) ?? [] }
                  .sink { [weak self] tasks in
                      self?.todos = tasks
                  }
          }
      
          func addTask(title: String) throws {
              try tasks.save(from: Task(title: title, completed: false))
          }
      
          func toggleCompletion(for task: Task) throws {
              if let index = todos.firstIndex(where: { $0.id == task.id }) {
                  todos[index].completed.toggle()
                  try tasks.save(from: todos[index])
              }
          }
      
          func deleteTask(_ task: Task) throws {
              try tasks.delete(for: task)
          }
      }

      View

      • Combine Framework

      • Observation Framework

      For iOS 13 and later, or macOS 10.15 to 14:

      ​​struct TodoListView: View {
          @StateObject private var viewModel = TodoViewModel()
      
          var body: some View {
              List {
                  ForEach(viewModel.todos) { task in
                      HStack {
                          Text(task.title)
                          Spacer()
                          Button(action: {
                              try? viewModel.toggleCompletion(for: task)
                          }) {
                              Image(systemName: task.completed ? "checkmark" : "circle")
                                  .foregroundColor(task.completed ? .green : .gray)
                          }
                      }
                  }
                  .onDelete { indexSet in
                      indexSet.forEach { index in
                          let task = viewModel.todos[index]
                          try? viewModel.deleteTask(task)
                      }
                  }
              }
              .toolbar {
                  ToolbarItem(placement: .navigationBarTrailing) {
                      Button(action: { try? viewModel.addTask(title: "New Task") }) {
                          Image(systemName: "plus")
                      }
                  }
              }
          }
      }

      For iOS 17 and later, or macOS 14 and later:

      ​struct TodoView: View {
          @State var viewModel = TodoViewModel()
          @State private var newTaskTitle = ""
      
          var body: some View {
              VStack {
                  TextField("New Task", text: $newTaskTitle)
                      .textFieldStyle(.roundedBorder)
                      .padding()
      
                  Button("Add") {
                      guard !newTaskTitle.isEmpty else { return }
                      try? viewModel.addTask(title: newTaskTitle)
                      newTaskTitle = ""
                  }
      
                  List(viewModel.todos, id: \.id) { task in
                      HStack {
                          Button(action: { try? viewModel.toggleCompletion(for: task) }) {
                              Image(systemName: task.completed ? "checkmark.circle.fill" : "circle")
                          }
      
                          Text(task.title)
                              .strikethrough(task.completed)
      
                          Spacer()
      
                          Button(action: { try? viewModel.deleteTask(task) }) {
                              Image(systemName: "trash")
                          }
                      }
                  }
              }
              .padding()
          }
      }