Should ObservationRegistrar willSet/didSet Always Be Paired?

I'm in the process of migrating my data persistence library Boutique from being an ObservableObject supporting @Observable types. For the most part the process has been straightforward, but I have two property wrappers (@StoredValue and @SecurelyStoredValue that now work, with one outstanding question.

These two property wrappers are responsible for updating the value of an item in UserDefaults and the system keychain, respectively. To accomplish this and inform any @Observable objects that hold these values that the value has changed, I'm leaning on ObservationRegistrar.

Here is a code sample of how these property wrappers are expected to be used.

@Observable
final class AppState {
    @ObservationIgnored
    @StoredValue(key: "funkyRedPandaModeEnabled")
    var funkyRedPandaModeEnabled = false

    @ObservationIgnored
    @SecurelyStoredValue(key: "account")
    var userAccount: Account? = nil
}

Elsewhere in the app, you may have a mutation occur (imagine a SwiftUI button) that calls:

self.appState.funkyRedPandaModeEnabled.toggle()

When this occurs, our StoredValue<Bool> would toggle from true to false (or vice versa). In my demo app I use funkyRedPandaModeEnabled to determine whether a user sees a rainbow overlay on top of their red panda image, demonstrating how to build a state-driven app using Boutique.


Now for the main question at hand. Both @StoredValue and @SecurelyStoredValue use ObservationRegistrar to inform owners of this property when a value has changed. I use it in any mutating function, such as SecurelyStoredValue's insert().

func insert(_ value: Item) throws {
    observationRegistrar.willSet(self, keyPath: \.wrappedValue)

    let keychainQuery = [
        kSecClass: kSecClassGenericPassword,
        kSecAttrService: self.keychainService,
        kSecAttrAccount: self.key,
        kSecValueData: try JSONCoders.encoder.encodeBoxedData(item: value)
    ]
    .withGroup(self.group)
    .mapToStringDictionary()

    let status = SecItemAdd(keychainQuery as CFDictionary, nil)

    if status == errSecSuccess || status == errSecDuplicateItem {
        self.valueSubject.send(value)
        observationRegistrar.didSet(self, keyPath: \.wrappedValue)
    } else {
        throw KeychainError(status: status)
    }
}

In the example above, I'm only calling observationRegistrar.didSet(self, keyPath: \.wrappedValue) when the value is successfully changed, because if there is a keychain error, then the value does not actually change so I'm unsure whether to call didSet.

I couldn't find any documentation for this, but is it expected that if observationRegistrar.willSet is called that observationRegistrar.didSet must be called as well?

  • If it's only expected to call didSet when a successful mutation occurred, then I need to use this strategy of selectively calling didSet.
  • But so I should be able to call didSet in a defer block, or use withMutation.
1 Like

For future proofing I would strongly suggest to always pair will/did set calls.

1 Like

Thanks @Philippe_Hausler, that's really good to know!

1 Like