SwiftUI in the Wild: Memory, Concurrency, and the Gaps in the Docs
Part of a series
- Swift & SwiftUI Series (2 of 2)
A collection of hard-won patterns, and a few landmines, for building real SwiftUI apps with Swift's modern concurrency model. Covers view model lifetime bugs, debouncing, async button patterns, task ownership, reference cycles, and the @Observable + actor tension. Some of this is in the docs; some of it you only find out when something doesn't deinit and you can't figure out why.
1. @State + @Observable lifecycle: When does SwiftUI release the view model?
Start here. This is one of the most surprising gotchas in the post-iOS 17 SwiftUI model, and it has downstream consequences for every pattern involving cleanup and task cancellation.
The short answer: Apple's official docs do not give a precise guarantee on when @State releases a reference-type object. The bugs described below have had a complicated history — some fixed, some not — and iOS 26 has already introduced new regressions.
What Apple officially says
The migration guide and WWDC23 "Discover Observation in SwiftUI" say: if the view owns the model, use @State. The docs describe @State as tying the object's lifetime to the view's identity, mirroring what @StateObject did for ObservableObject. But they do not specify exactly when deallocation happens relative to the view leaving the hierarchy.
What actually happens
Sheets and modal presentations — fixed post-iOS 17.1, but with caveats
The most widely reported bug: @State view models in sheet/fullScreenCover presentations were never deallocated after dismissal; deinit was never called. This was confirmed as an iOS 17.0 regression — the same pattern worked correctly on iOS 16. (Apple Forums, Apple Forums)
The fix history is messy. The bug was addressed in iOS 17.2 beta 1, then regressed, then fixed again. The author of the main third-party workaround package (SwiftUIMemoryLeakWorkaround) confirmed the underlying bug was resolved sometime after iOS 17.1 and marked the package as no longer needed. There are no confirmed developer reports of this specific sheet leak reproducing on iOS 18.
However: Apple has never documented a deallocation timing contract for @State + @Observable. Even on a patched OS, "not leaking" is not the same as "deinit fires promptly and reliably."
NavigationStack destinations — unresolved
Views pushed onto a NavigationStack do not fully tear down their state on pop. @State-held view models re-initialise on each push without a corresponding deinit from the previous instance. (Apple Forums)
This is a distinct issue from the sheet leak and has its own tracking. There is no confirmed fix in any iOS 17 or iOS 18 release notes. The .id() workaround (forcing SwiftUI to treat a view as a new identity) does trigger correct deallocation but is a workaround, not a fix.
@Observable classes in general
Classes held by views via @State are not deinitialized as expected in multiple contexts beyond sheets and navigation. (Swift Forums)
iOS 26 — new regression
A new class of bug has appeared in iOS 26.1 beta: updating a @State field value no longer triggers a view re-render, even though the state updates internally. This is confirmed as a regression from iOS 26.0 and is unrelated to the deallocation bugs above, but signals that @State + @Observable behaviour continues to be actively destabilised across OS releases. (Apple Developer Forums — Observation tag)
What this means in practice
You cannot rely on deinit for cleanup when using @State + @Observable in sheets or NavigationStack destinations. Even where the gross memory leak has been fixed, Apple provides no timing guarantee on deallocation. If your view model starts a timer, opens a stream, or kicks off an async task, deinit is not a safe place to cancel it.
This makes onDisappear-based cleanup not just a nice pattern, but a necessity:
.onDisappear {
viewModel.cancelAllWork()
}
This is the correct approach given current SwiftUI behaviour, not a workaround you should expect to remove once a bug is fixed.
Sources
- Migrating from ObservableObject to Observable macro — Apple Docs
- Discover Observation in SwiftUI — WWDC23
- @Observable/@State memory leak — Apple Forums
- @State ViewModel memory leak in iOS 17 — Apple Forums
- NavigationStack memory leak — Apple Forums
- Observable class not deinitialized — Swift Forums
- SwiftUI ViewModel not being deinit — Swift Forums
- SwiftUI view leaks in iOS 17 — Apple Forums
- SwiftUIMemoryLeakWorkaround — John Bafford (sheet fix confirmed post-17.1)
- iOS 26.1 @State re-render regression — Apple Developer Forums
2. Debouncing with async/await
Debouncing is a common need. You want to delay work until the user has stopped typing, tapping, or otherwise triggering rapid events. The async/await pattern for this is clean once you know the shape.
The canonical pattern:
@MainActor
final class ViewModel {
private var debounceTask: Task<Void, Never>?
func debouncedCount(text: String) {
debounceTask?.cancel()
debounceTask = Task { [weak self] in
do {
try await Task.sleep(for: .milliseconds(300))
guard let self else { return }
self.performCount(text: text)
} catch is CancellationError {
return
} catch {
assertionFailure("Unexpected debounce error: \(error)")
}
}
}
private func performCount(text: String) {
// update state
}
}
The steps:
- Cancel the previous task.
- Start a new task.
- Sleep for the debounce interval.
- Treat
CancellationErroras expected control flow and return immediately. - Only run the real work after the sleep completes uninterrupted.
The critical mistake to avoid is using try? await Task.sleep(...). That silently swallows the cancellation error and lets the debounced work run anyway, exactly what you do not want.
On actor context: if performCount touches UI or view-model state, it should be @MainActor-isolated. If your view model is already @MainActor as above, you generally do not need extra MainActor.run calls. Just make sure performCount is properly isolated.
3. Async work in button actions
SwiftUI's Button does not natively accept an async action closure, so there are a few patterns depending on how much control you need.
Option 1: Task {} in the action
Simple, but no auto-cancellation.
Button("Fetch") {
Task {
await fetchData()
}
}
The Task inherits the actor context of the view, usually the main actor, so UI updates are safe. The downside is that if the user taps again, a second task launches alongside the first.
Option 2: .task(id:)
Auto-cancelling, driven by state.
@State private var fetchID = 0
Button("Fetch") { fetchID += 1 }
.task(id: fetchID) {
guard fetchID > 0 else { return }
await fetchData()
}
Whenever fetchID changes, SwiftUI cancels the previous task and starts a new one. This is useful for search, debounce, or anything where only the latest request matters.
Option 3: @State Task
Manual control, most flexible.
@State private var currentTask: Task<Void, Never>?
Button("Fetch") {
currentTask?.cancel()
currentTask = Task {
await fetchData()
}
}
You manage the lifecycle yourself, which means more boilerplate but more control. You can cancel on retap and in .onDisappear, inspect task state, and keep the cancellation handle wherever it best fits.
| Pattern | Auto-cancel on retap | Lifecycle ownership |
|---|---|---|
Task {} in action |
No | Manual |
.task(id:) |
Yes | SwiftUI |
@State Task |
Yes, manual | Manual |
4. Storing a Task in a SwiftUI view
When you need a cancellable task handle inside a view, @State is the right storage:
struct ContentView: View {
@State private var reloadTask: Task<Void, Never>?
var body: some View {
Button("Reload") {
reloadTask?.cancel()
reloadTask = Task {
await viewModel.reload()
}
}
.onDisappear {
reloadTask?.cancel()
}
}
}
Why @State? Task is a value type, but it wraps a reference to the underlying async work. Storing it in @State gives you stable heap storage across re-renders, so you are always holding a reference to the same task handle rather than a copy that gets discarded on the next body evaluation.
Other properties of this pattern:
- You get a cancellation handle you can call
.cancel()on at any time. - The task handle's lifetime is tied to the view.
- Assigning to the property does not trigger a view re-render, since
Taskdoes not conform to any observable protocol.
When to put the task on the view model instead: if the async work is conceptually owned by the model rather than a specific UI interaction, storing the task there keeps the view leaner and the logic easier to test.
5. Closures, captures, and reference cycles
A common source of confusion: when does capturing self in a closure create a retain cycle?
The key insight is that a cycle does not require two distinct objects. It only requires a cycle in the reference graph. When a class holds a closure that captures self:
self -> closure -> self
That is a cycle. self holds a strong reference to the closure via the stored property, and the closure holds a strong reference back to self via the capture. ARC cannot find a zero-reference point to deallocate either one.
class Foo {
var action: (() -> Void)?
func setup() {
action = {
self.doSomething() // strong capture - cycle
}
}
func doSomething() { print("hello") }
deinit { print("deallocated") } // never prints
}
Fix it with [weak self]:
action = { [weak self] in
self?.doSomething()
}
The one case where there is no cycle is when the closure is not stored. A closure passed to UIView.animate does not cause a retain cycle even with a strong self capture because the animation system releases the closure after it fires, so the reference is transient.
The rule of thumb: if a closure is stored as a property and captures self, use [weak self].
6. @Observable and actors: The tension
@Observable and actor can coexist syntactically, but they are in tension by design.
@Observable
actor CounterActor {
var count = 0
func increment() { count += 1 }
}
The problem is that @Observable synthesizes observation registrar hooks such as _$observationRegistrar.access(...) and mutation tracking that need to be coordinated with main-actor-driven UI observation, while an actor's properties must be accessed on that actor's executor. The mismatch produces compiler warnings about non-Sendable types crossing actor boundaries.
The practical solutions
1. Use @MainActor instead
Most common for UI.
@Observable
@MainActor
class CounterModel {
var count = 0
func increment() { count += 1 }
}
This gives you observation plus main-thread safety, which is almost always what you want for SwiftUI state.
2. Separate the actor from the observable model
Usually the cleanest architecture.
actor DataService {
func fetchCount() async -> Int { ... }
}
@Observable
@MainActor
class ViewModel {
var count = 0
private let service = DataService()
func load() async {
count = await service.fetchCount()
}
}
The actor handles concurrency and isolation for background work. The @Observable @MainActor class handles UI observation. Each does one thing.
The mental model: use actor for work that genuinely needs background isolation or serialized access to shared state. Use @Observable @MainActor class for anything that drives SwiftUI views. Bridge between them with await.
More patterns to come. Feed the machines. 🤖🤤