# Tracking properties in `@Observable` models internally

I have a view model type `@Observable` model object that reads inputs and processes them into outputs. With `@ObservedObject` and `@Published` wrappers it was straightforward to setup the pipelines using publishers. Now that I intend to migrate to `@Observable`, how do I observe and react to changes to the inputs within the model itself?

``````import SwiftUI

@main
struct CalculatorApp: App {

var body: some Scene {
WindowGroup {
CalculatorView()
}
}

}

struct CalculatorView: View {

@StateObject var calculatorObservableObject = CalculatorObservableObject()
@Bindable var observableCalculator = ObservableCalculator()

var body: some View {
Form {
Section("ObservedObject") {
TextField("a", value: \$calculatorObservableObject.a, format: .number)
TextField("b", value: \$calculatorObservableObject.b, format: .number)
Text(calculatorObservableObject.sum, format: .number)
}
Section("Observable") {
TextField("a", value: \$observableCalculator.a, format: .number)
TextField("b", value: \$observableCalculator.b, format: .number)
Text(observableCalculator.sum, format: .number)
}
}
}

}

final class CalculatorObservableObject: ObservableObject {

@Published var a: Int
@Published var b: Int
@Published private(set) var sum: Int

init(a: Int = 0, b: Int = 0) {
self.a = a
self.b = b
self.sum = a + b

\$a.combineLatest(\$b)
.map(+)
.assign(to: &\$sum)
}

}

@Observable final class ObservableCalculator {

var a: Int
var b: Int
private(set) var sum: Int

init(a: Int = 0, b: Int = 0) {
self.a = a
self.b = b
self.sum = a + b

//	How to track further changes to `a` and `b` and update `sum` accordingly?

//	These are all the options I've tried so far, none of them work:
withObservationTracking(
{
print(self.a)
print(self.b)
},
onChange: {
self.sum = self.a + self.b
}
)
self.sum = withMutation(keyPath: \.sum) {
self.a + self.b
}
withMutation(keyPath: \.sum) {
self.sum = self.a + self.b
}
}

}
``````

If it matters, I'm using Xcode 15b5 on macOS Ventura 13.4.1

4 Likes

Would this not be suitable?

``````	var a: Int
var b: Int
private var sum: Int { a + b }
``````

That way you don't need to deal with tracking anything or intercepting `withMutation` events. Alternatively if you provide a `withMutation` function yourself that will be used instead of the synthesized version (however be careful not to be recursive within that - mutations inside of `withMutation` should be done such that it tests the keypath first and then mutates only when needed).

The code I've posted here is oversimplified for the sake of example. In my actual use case, calculating my private set property has O(n) complexity. Making it a computed property would cause the expensive operation to run each time it's accessed which is why I want to set it only when the dependent properties change.

2 Likes

If you only need to combine the latest values into some aggregate value, then I think the easiest way to approximate that would be to hook into the `didSet` of the dependency fields to call a shared compute function for performing the heavyweight work:

``````var a: Int {
didSet { self.computeSum() }
}
var b: Int {
didSet { self.computeSum() }
}
private var sum: Int
func computeSum() {
self.sum = self.a + self.b
}
``````

However, if you need to use other, more complex Combine operators, such as throttling, debounce, etc., then you will have to do a lot more work to capture that.

I'm also using quite a few of those observers in my view models. As @prathameshk mentioned, it was super easy to set up those observations with a Combine pipeline in the view models initializer or a dedicated method.

Using computed properties is okay for some cases. And for other cases there exists the new `withObservationTracking` method. But in my opinion this is a huge step backwards. Especially since `withObservationTracking ` is only executed once unless you wrap it in a function and call the function again in the `onChange` closure. This is a lot of boilerplate and rather awkward.

For example, I got a `name` property in my view model, when this property changes, I want to change the `name` property in an object that is held by the view model. I don't want to change the property of the object directly, and I also don't want this logic happen in my views.

Is there any way of doing this without using something like:

``````func observationOfName() {

withObservationTracking {
myObject.name = name
} onChange: { [weak self] in
self?.observationOfName()
}
}
``````

Hi @marcoboerner, is it not sufficient to replay the name change in the `didSet`?

``````var name: Sting {
didSet {
self.myObject.name = name
}
}
``````

Hi @mbrandonw thanks for your advice! In this particular case this would work, but in some more advanced cases where I used a bunch of publisher operations before, that would mean I have to somehow recreate that functionality/logic within the `didSet`. It's doable, but I found Combine here much cleaner and easier to understand. So far I'm not completely convinced.

Yeah, I generally agree that this will not work in all cases. It's always worth trying it out to see if it works, but if you need some of the more advanced tools of Combine (such as debounce, throttle, etc), then you will have to recreate that work in an ad-hoc manner.

1 Like

I found a solution from two parts:

1. This gist that returns a publisher for a given keypath. The downside is there's no way to limit the keypath parameter to @ObservationTracked keypaths only, so a publisher can be formed for @ObservationIgnored keypaths.
2. This gist from @jasdev that lets you assign to weakly captured self, as Combine's `assign(to:on:)` captures its object strongly.

Thanks to it, my observable model now looks like this:

``````@Observable final class ObservableCalculator {

var a: Int
var b: Int
private(set) var sum: Int

private var cancellables: Set<AnyCancellable> = []

init(a: Int = 0, b: Int = 0) {
self.a = a
self.b = b
self.sum = a + b

publisher(keyPath: \.a)
.combineLatest(publisher(keyPath: \.b))
.map(+)
.assign(to: \.sum, onWeaklyCaptured: self)
.store(in: &cancellables)
}

}
``````

It still needs Combine to work and uses the recursive call to `withObservationTracking` under the hood, and does not address the fact that continued observation is still required in the library.

Edit: Welp never mind I may have spoken too soon. It does not seem to work.

Hi @prathameshk, I highly recommend you do not use that publisher tool. The use of `Task { … }` in `withObservationTracking` in order to get the changed value has potential race conditions. This test shows the problem:

``````@Observable
class Model {
var count = 0
}

final class ObservablePublisherTests: XCTestCase {
func testBasics() async throws {
let model = Model()
let countPublisher = model.publisher(keyPath: \Model.count)

var values: [Int] = []
let cancellable = countPublisher.sink { int in
values.append(int)
}

let max = 10
for _ in 1...max {
model.count += 1
}

XCTAssertEqual(values.count, max)       // ✅
XCTAssertEqual(values, Array(1...max))  // ❌

_ = cancellable
}
}
``````

This test does pass sometimes, but it will fail if you run it repeatedly, and you will get a failure like this: XCTAssertEqual failed: ("[1, 2, 3, 4, 5, 6, 7, 7, 9, 10]") is not equal to ("[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]")

This shows that the `8` value was not emitted and instead a stale `7` value took its place. This is due to the race condition of using the unstructured `Task`.

4 Likes

Yikes @mbrandonw that's not good. We're back to square one then.

Yea I have this question as well. Especially if you want to observe a property internally to pipe into an `AsyncSequence`

I messed around some more over the weekend and found this solution. It involves inlining both the `@Observable` and `@ObservationTracked` macros to write to private @Published properties instead of @ObservationIgnored properties:

``````import Combine
import Observation
import SwiftUI

@main
struct CalculatorApp: App {

@Bindable private var calculator = ObservableCalculator(a: 0, b: 0)

var body: some Scene {
WindowGroup {
Form {
Section {
TextField("a", value: \$calculator.a, format: .number)
TextField("b", value: \$calculator.b, format: .number)
}
Section {
Text(calculator.sum, format: .number)
}
}
}
}

}

final class ObservableCalculator: Observation.Observable, ObservableObject {

var a: Int {
init(initialValue) initializes (__a) {
__a = .init(initialValue: initialValue)
}

get {
access(keyPath: \.a)
return _a
}

set {
withMutation(keyPath: \.a) {
_a = newValue
}
}
}
@Published private var _a: Int

var b: Int {
init(initialValue) initializes (__b) {
__b = .init(initialValue: initialValue)
}

get {
access(keyPath: \.b)
return _b
}

set {
withMutation(keyPath: \.b) {
_b = newValue
}
}
}
@Published private var _b: Int

private(set) var sum: Int {
init(initialValue) initializes (__sum) {
__sum = .init(initialValue: initialValue)
}

get {
access(keyPath: \.sum)
return _sum
}

set {
withMutation(keyPath: \.sum) {
_sum = newValue
}
}
}
@Published private var _sum: Int

private let _\$observationRegistrar = Observation.ObservationRegistrar()
private var cancellables: Set<AnyCancellable> = []

init(a: Int, b: Int) {
self.a = a
self.b = b
self.sum = a + b

Publishers.CombineLatest(\$_a, \$_b)
.map(+)
.assign(to: \.sum, onWeaklyCaptured: self)
.store(in: &cancellables)
}

internal nonisolated func access<Member>(
keyPath: KeyPath<ObservableCalculator, Member>
) {
_\$observationRegistrar.access(self, keyPath: keyPath)
}

internal nonisolated func withMutation<Member, T>(
keyPath: KeyPath<ObservableCalculator, Member>,
_ mutation: () throws -> T
) rethrows -> T {
try _\$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}

}

extension Publisher where Failure == Never {

func assign<Root: AnyObject>(
to keyPath: ReferenceWritableKeyPath<Root, Output>,
onWeaklyCaptured object: Root
) -> AnyCancellable {
sink { [weak object] value in
object?[keyPath: keyPath] = value
}
}

}
``````

As a bonus, you could conform your model to ObservableObject and get both the new and old style of observability in one definition.

I wrote a basic unit test to confirm that publishing works correctly, but more testing is likely needed.

``````final class CalculatorTests: XCTestCase {

func testCalculator() async throws {
let calculator = ObservableCalculator(a: 0, b: 0)

var values: [Int] = []
let cancellable = calculator.\$_sum
.dropFirst()
.sink { values.append(\$0) }

let inputsA = Array(1...10)
for _ in inputsA {
calculator.a += 1
}
let inputsB = Array(1...10)
for _ in inputsB {
calculator.b += 1
}

XCTAssertEqual(values.count, inputsA.count + inputsB.count)
XCTAssertEqual(values, Array(1...20))

_ = cancellable
}

}
``````

Finally, if someone who knows macros better than me could come along and repackage all the inlined macro-generated code into a new macro that would be great.

2 Likes
{"ft_lang_label":"__label__en","ft_lang_prob":0.7428428,"math_prob":0.8152289,"size":10173,"snap":"2023-40-2023-50","text_gpt3_token_len":2556,"char_repetition_ratio":0.13826334,"word_repetition_ratio":0.1209068,"special_character_ratio":0.2544972,"punctuation_ratio":0.18852894,"nsfw_num_words":0,"has_unicode_error":false,"math_prob_llama3":0.9772903,"pos_list":[0,1,2,3,4],"im_url_duplicate_count":[null,null,null,1,null],"WARC_HEADER":"{\"WARC-Type\":\"response\",\"WARC-Date\":\"2023-11-29T18:58:27Z\",\"WARC-Record-ID\":\"<urn:uuid:08aaf994-2f91-491d-b599-28bd766731a7>\",\"Content-Length\":\"51340\",\"Content-Type\":\"application/http; msgtype=response\",\"WARC-Warcinfo-ID\":\"<urn:uuid:b3f2ce76-14ff-4cf7-811b-71eecc2526e1>\",\"WARC-Concurrent-To\":\"<urn:uuid:ee4c06ff-36f1-4d50-b2cd-ff19bafc471d>\",\"WARC-IP-Address\":\"184.105.99.75\",\"WARC-Target-URI\":\"https://forums.swift.org/t/tracking-properties-in-observable-models-internally/66743\",\"WARC-Payload-Digest\":\"sha1:UREHNEYW2O3NACBY7ZI2GYN5FA25ERZD\",\"WARC-Block-Digest\":\"sha1:4Q6UMA46U3J75LH6I5EP66XQ2KQZHJ22\",\"WARC-Identified-Payload-Type\":\"text/html\",\"warc_filename\":\"/cc_download/warc_2023/CC-MAIN-2023-50/CC-MAIN-2023-50_segments_1700679100135.11_warc_CC-MAIN-20231129173017-20231129203017-00817.warc.gz\"}"}