How to wrap UserDefaults with Observable in SwiftUI

Issue #1009

SwiftUI’s Observation framework makes reactive UI updates effortless—when a property changes, views that depend on it automatically refresh. But what happens when your data lives in UserDefaults? You might want subscription status, user preferences, or feature flags to persist across launches while still triggering view updates when they change.

The natural instinct is to wrap UserDefaults access in an @Observable class. This mostly works, but there’s a subtle gap: if something outside your class writes to UserDefaults directly—an app extension, a StoreKit callback, or another module—your UI won’t update. The good news? Once you understand how the Observation framework tracks changes internally, bridging this gap becomes straightforward.

Here’s a typical first attempt at making subscription status both persistent and observable:

@Observable
class SubscriptionState {
    var hasActiveSubscription: Bool {
        get {
            access(keyPath: \.hasActiveSubscription)
            return UserDefaults.standard.bool(forKey: "hasActiveSubscription")
        }
        set {
            withMutation(keyPath: \.hasActiveSubscription) {
                UserDefaults.standard.set(newValue, forKey: "hasActiveSubscription")
            }
        }
    }
}

This approach works when changes flow through your SubscriptionState instance. But here’s the catch—if something else writes to UserDefaults directly (an app extension, another module, or even UserDefaults.standard.set called elsewhere), your observers stay silent. The Observation framework only knows about changes you explicitly tell it about.

How ObservationRegistrar Actually Works

To solve this, we need to peek behind the curtain. When you apply @Observable to a class, Swift generates an ObservationRegistrar that does the heavy lifting. Think of it as a switchboard operator—it tracks which views are watching which properties and routes change notifications accordingly.

The registrar exposes two key methods that make the whole system tick:

class SubscriptionState {
    @ObservationTracked var hasActiveSubscription: Bool = false

    private let _$observationRegistrar = ObservationRegistrar()

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

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

That generated code does two important things:

  1. access(_:keyPath:) records a dependency. When SwiftUI reads a property, this method tells the registrar “this view cares about this keyPath.”
  2. withMutation(of:keyPath:_:) broadcasts a change. When you modify a property, this method notifies every observer watching that keyPath to re-evaluate.

The insight here is that withMutation doesn’t care what changed—it just signals that something changed. Observers then re-read the property to get the new value.

Bridging External Changes

With that understanding, the solution becomes clear: expose the registrar so you can manually trigger notifications when UserDefaults changes outside your observable class.

@Observable
class SubscriptionState {
    var observationRegistrar: ObservationRegistrar {
        _$observationRegistrar
    }

    var hasActiveSubscription: Bool {
        get {
            access(keyPath: \.hasActiveSubscription)
            return UserDefaults.standard.bool(forKey: "hasActiveSubscription")
        }
        set {
            withMutation(keyPath: \.hasActiveSubscription) {
                UserDefaults.standard.set(newValue, forKey: "hasActiveSubscription")
            }
        }
    }
}

Now, when you detect an external change to UserDefaults—perhaps through NotificationCenter or after a StoreKit transaction completes—you can manually notify observers:

// Signal that subscription status changed externally
subscriptionState.observationRegistrar.withMutation(of: subscriptionState, keyPath: \.hasActiveSubscription) {}

The empty closure might look odd, but it’s intentional. You’re not performing a mutation—you’re simply telling the system “hey, this value is different now, anyone who cares should take another look.”

Keeping Concerns Separate

One practical pattern is to keep your observable app state distinct from UserDefaults-backed state:

@Observable
class AppState {
    var currentTab = 0
    var isOnboarding = false
    let subscriptionState = SubscriptionState()
}

In-memory state like currentTab gets precise observation automatically. Subscription state reads from UserDefaults on access and can receive external change notifications when StoreKit transactions complete. Each gets the right tool for the job.

Beyond UserDefaults

The pattern we’ve explored here—exposing ObservationRegistrar to manually trigger change notifications—applies whenever you need to bridge observable state with external data sources. Whether you’re syncing with CloudKit, receiving updates from a WebSocket, or coordinating with an app extension, the principle remains the same: the registrar only knows what you tell it.

By understanding how access and withMutation work under the hood, you gain the ability to integrate the Observation framework with practically any data source while maintaining SwiftUI’s reactive updates.

Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation