One subtle trap that many iOS developers fall into when using MVVM with Swift and SwiftUI is creating implicit strong references to the view model through closures. These references can easily lead to retain cycles, especially when closures are passed directly to SwiftUI view modifiers or Combine operators.
Because the syntax looks clean and concise, the problem often goes unnoticed until memory leaks start appearing during testing or profiling.
This article highlights two common scenarios where this happens and how to avoid them.
1. Passing ViewModel Methods Directly to SwiftUI Modifiers
A common pattern in SwiftUI is to pass a view model method directly to a view modifier such as .onAppear.
Problematic Example
Text("Hello, World!")
.onAppear(perform: viewModel.onAppear)
At first glance, this looks perfectly fine. However, this implicitly captures viewModel strongly. If the view model holds references that ultimately retain the view hierarchy, this can create a retain cycle.
This happens because Swift creates a closure that strongly captures viewModel when the method reference is passed.
Safer Approach
Instead of passing the method directly, wrap it in a closure and capture the view model weakly:
Text("Hello, World!")
.onAppear { [weak viewModel] in
viewModel?.onAppear()
}
This ensures that the closure does not keep the view model alive if it should be deallocated.
2. Combine assign Can Capture self Strongly
Another place where retain cycles commonly occur is in Combine pipelines, particularly when using assign.
Problematic Example
@Published var state: State
stateSubject.assign(to: &$state)
While this syntax is convenient, it can lead to strong capture of the owning object (such as the view model), depending on how the pipeline is structured. This may unintentionally keep the view model alive.
Safer Alternative Using sink
Using sink allows explicit control over how self is captured:
stateSubject
.sink { [weak self] value in
self?.state = value
}
.store(in: &cancellables)
Here, self is captured weakly, preventing the Combine pipeline from retaining the view model.
Why This Is Easy to Miss
These issues are particularly tricky because:
- The syntax looks clean and idiomatic
- SwiftUI and Combine often hide closure creation
- The retain cycle may only appear under specific lifecycle conditions
Even experienced developers can introduce these bugs without realizing it.
Key Takeaways
- Avoid passing view model methods directly to SwiftUI modifiers when
lifecycle matters. - Prefer explicit closures with weak captures.
- Be cautious with Combine operators like
assign. - When in doubt, inspect memory graphs in Xcode to confirm that view
models are being released.
Final Thoughts
Some retain cycles in Swift are rarely obvious. Many occur not because of incorrect architecture, but because of implicit strong captures created by elegant Swift syntax.
Being mindful of how closures capture objects — especially in SwiftUI modifiers and Combine pipelines — can prevent subtle memory leaks that are otherwise difficult to detect.