Greetings, traveler!
Every experienced iOS engineer eventually runs into the same unsettling moment: you navigate back from a screen, expect deinit to fire, and nothing happens. The view disappears but the memory does not.
In SwiftUI projects, this behavior has repeatedly surfaced around three modifiers: onSubmit, searchable, and refreshable. The pattern looks similar each time. A screen inside NavigationStack owns a view model. You pop the screen but the view model remains alive.
After investigating multiple reproducible examples, forum threads, and minimal test cases, a more nuanced picture emerges. Some scenarios involve classic retain cycles. Others appear to stem from internal SwiftUI behavior.
This article breaks down what actually happens, why it happens, and how to structure code to avoid painful surprises.
The onSubmit case
A minimal reproducible example published on Apple Developer Forums demonstrates a surprising behavior: a TextField inside NavigationStack with an onSubmit handler prevents the view model from deallocating after navigating back. Removing onSubmit restores expected behavior. Switching to the deprecated TextField(_:text:onCommit:) also avoids the issue.
The structure is simple:
NavigationStack {
NavigationLink("Push") {
DetailView()
}
}
struct DetailView: View {
@State private var viewModel = DetailViewModel()
var body: some View {
TextField("Text", text: $viewModel.text)
.onSubmit {
viewModel.performSmth()
}
}
}After triggering submit and navigating back, deinit does not fire.
There are two possible explanations.
First, lifecycle mismanagement. Storing an ObservableObject in @State can produce unpredictable ownership semantics. @StateObject was introduced specifically to give SwiftUI stable object identity. Using @State for an ObservableObject invites subtle retention issues.
Second, framework-level retention. onSubmit bridges into UIKit’s text input system. If SwiftUI or UIKit retains the submit handler or responder chain longer than expected, the closure may hold onto the view model. To prevent such behaviour, you can weaken the reference:
TextField("Text", text: $viewModel.text)
.onSubmit { [weak viewModel] in
viewModel?.performSmth()
}In practice, both factors matter. Replacing @State with @StateObject (if you are using Combine’s ObservableObject) resolves many cases. In others, you can weaken the reference.
The searchable and refreshable combination
Another widely shared minimal example demonstrates retention when combining ScrollView, searchable, and refreshable. The screen owns a @State or a @StateObject view model containing a large in-memory array to make retention obvious. After navigating away, deinit does not run.
The interesting detail is that the issue often appears when both modifiers are present together.
searchableintegrates a search controller into navigation.refreshableattaches a refresh control and asynchronous refresh pipeline.
When combined with NavigationStack, SwiftUI may preserve parts of the hierarchy for reuse or state restoration.
This does not always indicate a true leak. Sometimes objects deallocate later, after further UI interactions. Other times, Instruments reveals a genuine retain cycle involving a closure, a task, or a subscription.
The behavior feels inconsistent because it depends on container type, OS version, and navigation structure.
Retain cycles inside closures
Before blaming SwiftUI, it is worth inspecting the obvious.
If closures capture self strongly, and self owns something that indirectly retains the closure, a cycle forms.
For example:
.refreshable {
await viewModel.reload()
}If reload() creates a long-running task and stores it inside the view model, and the refresh closure remains retained by the view hierarchy, you have a graph that can easily become cyclic.
Using a weak capture in refresh handlers is a pragmatic safeguard:
.refreshable { [weak viewModel] in
await viewModel?.reload()
}Container interactions and NavigationStack
NavigationStack adds another layer. SwiftUI may retain parts of the navigation tree for performance and state restoration. That means the disappearance of a view does not always coincide with deallocation.
When diagnosing a suspected leak:
- Navigate in and out multiple times.
- Trigger memory pressure in Simulator.
- Inspect Memory Graph for cycles rather than relying solely on missing
deinit.
If the object disappears after subsequent navigation or screen transitions, the behavior may reflect SwiftUI caching rather than a permanent leak.
That said, combining NavigationStack with searchable and refreshable has produced reproducible retention in some configurations.
When a minimal example shows retention that disappears after removing a single modifier, it is reasonable to treat it as a framework edge case and adjust architecture accordingly.
Ownership rules that prevent most problems
After reviewing these cases, several structural guidelines consistently reduce risk.
Use the correct property wrapper for reference types. If a view creates and owns a class instance, use @StateObject for ObservableObjects. If the instance is injected, use @ObservedObject or @Bindable (or just var for Observation framework if you don’t need any bindings). Avoid storing ObservableObjects in @State.
Keep closures thin. Delegate heavy logic to methods on the view model rather than embedding complex behavior inside modifier closures.
Capture weak references in closures that may be retained by UI infrastructure, especially in refreshable and onSubmit.
Manage asynchronous work explicitly. Cancel tasks and release subscriptions tied to search pipelines.
Test minimal configurations. Remove modifiers one by one to identify which layer affects lifetime.
Conclusion
SwiftUI gives us elegant APIs. It still requires disciplined memory management.
