StoreKit 2 lifetime unlock in a Swift 6 SwiftUI app: three things that bit me
This app was later renamed Deep Cut Atlas. It’s called “Discoverer” throughout below, because that’s what it was called on the day this happened.
I added a one-time “lifetime Pro” unlock to a small SwiftUI app this week. StoreKit 2 makes the purchase itself almost boring - product.purchase(), check the result, done. What actually cost me time was the seam between StoreKit, Swift 6’s concurrency rules, and SwiftUI’s observation. Three separate things tripped me up, and none of them showed up the way I expected.
Here’s the shape of the feature: a non-consumable product, an @Observable service that owns the purchase state, and a Settings screen that flips from “Free” to “Pro” the moment you buy. All testable in the simulator with a local .storekit config, no Apple Developer account needed yet.
The happy path is short
StoreKit 2 is genuinely nice. The whole purchase flow is async/await and the types are small:
func purchase() async {
guard let product = proProduct else { return }
let result = try await product.purchase()
if case .success(let verification) = result,
case .verified(let transaction) = verification {
await transaction.finish()
await refreshEntitlements()
}
}
The one rule worth burning into memory: Transaction.currentEntitlements is the source of truth for what the user owns, not the result of any single purchase call. I cache a boolean in UserDefaults so the UI shows the right thing instantly on launch, but I always reconcile against currentEntitlements afterward. That cache is a convenience, never the authority.
Thing one: the protocol I reached for would have broken live updates
My app holds its services in an app-wide model. The existing pattern wraps each service in a protocol - any MusicLibraryServiceProtocol - so tests can swap in a mock. My instinct was to do the same for the store: any StoreServiceProtocol.
That instinct was wrong, and the reason is subtle. The music service is only ever called through its async methods. Nothing in the UI observes its properties. But the store’s isPro flag is the opposite - the Settings screen has to redraw the instant it flips. SwiftUI’s @Observable change tracking is the thing that makes that redraw happen, and reading a property through an any Protocol existential is exactly the case where that tracking gets shaky.
So I made the store a concrete type instead:
@MainActor @Observable
final class AppModel {
let musicLibrary: any MusicLibraryServiceProtocol // observed: no
let store: StoreService // observed: yes - concrete
}
I kept testability by injecting UserDefaults into the store instead of hiding it behind a protocol. The lesson I’m taking: “wrap it in a protocol for testing” is a fine default, but if the type’s properties drive a live UI, prefer the concrete @Observable. Observation through a concrete type is the guaranteed path.
Thing two: cancelling a Task in deinit fought the compiler
The store needs a long-lived listener for transactions that arrive outside a direct purchase - Ask-to-Buy approvals, refunds, restores from another device. That’s a Task iterating Transaction.updates, and I wanted to cancel it in deinit. Obvious code, instant rejection:
private var updatesTask: Task<Void, Never>?
deinit { updatesTask?.cancel() }
// main actor-isolated property 'updatesTask' can not be
// referenced from a nonisolated context
The class is @MainActor, but deinit is nonisolated - it can run on any thread - so it can’t touch a main-actor property. I tried the two things you’d try next. Plain nonisolated var is rejected because it only works on let. Adding nonisolated(unsafe) compiled but threw a warning, because the @Observable macro was still wrapping the property in tracked storage.
What actually worked was telling the macro to leave the property alone, and marking it unsafe-nonisolated:
@ObservationIgnored nonisolated(unsafe)
private var updatesTask: Task<Void, Never>?
The @ObservationIgnored is the load-bearing half. Without it, @Observable generates main-actor-isolated accessors for the property, and those accessors are what reintroduce the error. The task handle is internal plumbing, not UI state, so it had no business being observed in the first place. It’s safe because the handle is written once in init and read once in deinit, with no concurrent access, and Task.cancel() is thread-safe on its own.
Thing three: an error alert that silently did nothing
This one I only caught because a reviewer poked at the failure path. My store sets a lastErrorMessage string and the Settings screen shows it as an alert. Most of the time it worked. On one path - the early return when the product hadn’t loaded - tapping the button did absolutely nothing. No alert, no feedback, a dead button.
The cause is a SwiftUI observation rule I’d half-forgotten: body re-evaluates only when an @Observable property that was read during body changes. My alert read the error inside the binding’s get closure and the message closure - but those run later, called by the alert machinery, not while body is building. So lastErrorMessage was never registered as a dependency.
The paths that “worked” only worked by luck: they also flipped an isProcessing flag that the body does read for a spinner, which forced a redraw, during which SwiftUI re-checked the alert. The early-return path set the error and touched nothing else. No read, no redraw, no alert.
The fix is to read the value during body, and the presenting: overload does exactly that:
.alert(
"Something went wrong",
isPresented: errorPresented,
presenting: store.lastErrorMessage // read during body -> tracked
) { _ in
Button("OK") {}
} message: { message in
Text(message)
}
Now any change to the error re-evaluates body and the alert shows, no matter which path set it. The general rule I wrote down: if an @Observable value drives presentation through a closure but isn’t read in body, SwiftUI won’t react to it. Every value that should trigger a render has to be read while body runs.
What I’d tell myself before starting
The purchase API was the easy part. The hard part was three places where Swift 6’s isolation model and SwiftUI’s observation model meet, and in all three the compiler or the runtime behaved differently from the mental model I walked in with. Concrete types for observed state, @ObservationIgnored for plumbing you cancel in deinit, and read-it-in-body for anything that should redraw. None of those are in the StoreKit docs, because none of them are really about StoreKit.
Related reading
A filter bug report that turned into three separate issues
The reported filter was working perfectly - the screen in question was a different filter, deliberately independent, and the right fix was a third option entirely.
The setting that worked perfectly and felt completely broken
A filter default that only applied on relaunch, because a TabView keeps view models alive and state seeded at construction is frozen. The fix deleted a concept.
Shipping Deep Cut Atlas: what broke during my first App Store submission
A consumable that should have been non-consumable, a product ID you can never reuse, an archive that quietly didn't rebuild, and a rename with a two-week fuse.