Hidden Retain Cycle Traps in Swift & SwiftUI

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.

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.

Kamyab R. Bozorg

Software developer specializing in iOS development using Swift and SwiftUI, with experience in web development as well as in backend languages like C# and Java. My passion lies in crafting elegant solutions and pushing the boundaries of innovation in the ever-evolving world of technology.

Leave a Reply

Your email address will not be published. Required fields are marked *