SwiftUI View Models: Lifecycle Quirks

Luis Recuenco
The Swift Cooperative
8 min readDec 19, 2023
Photo by Nicola Ricca on Unsplash

Introduction

Coming from UIKit and imperative UI frameworks, SwiftUI is a paradigm shift. It tries to avoid sync issues between our view and our state (which presumably are one of the main sources of bugs in UI development), in a way where we simply tell SwiftUI the immutable description of how we’d like our view to look and the framework handles the rest. Views are usually subscribed to a source of truth so, when it changes, the “pure” function (State) -> View Description re-executes so that SwiftUI interprets that view description into a real view. We could simplify things and say that SwiftUI is all about two functions.

  • (State) -> View Description (Us)
  • (ViewDescription) -> Real View (SwiftUI)

This is all really nice. But there’s a subtle detail under all this: we no longer control the view lifecycle.

With UIKit, we did control when our views were created and destroyed. We had the instance, and we could do whatever we wanted with it. Now, the framework is in charge of those instances and real views are just implementation details. Not only that, we don’t control the lifecycle of the view descriptions either. Those view values can be called at any time, multiple times, and it’s also up to the framework to do that. We shouldn’t make any assumptions based on how things work today, because SwiftUI will likely change how our view values are created in the future.

Fair enough, we don’t control our views… But can we still have control over the underlying state of those views? That is, can we still control when our view models are created and destroyed? It’s still very common to hook onto some lifecycle methods like init and deinit inside our view models to subscribe and unsubscribe to different data sources, implement RAII patterns (like Combine’s Cancellable protocol, whose subscriptions are automatically canceled when destroyed), etc… Is that still something we can do? Should we instead rely on different lifecycle methods like onAppear and onDisappear? The truth is that, depending on the SwiftUI version, my opinion has changed. Let me rephrase that. Depending on the SwiftUI version, Apple has confused me even more.

Let’s start from the beginning.

It all started with @ObservedObject

Let’s have a simple example first. Let’s suppose we have two screens:

  • The first one computes a random number every two seconds and uses that number as the initial value to create a second screen when a button is tapped.
  • The second one uses that previous initial number and allows the user to increment or decrement that value.
struct ContentView: View {
@State private var showingSheet = false
@State private var number = 0

var body: some View {
Button("Show counter with value: \(number)") {
showingSheet = true
}
.sheet(isPresented: $showingSheet) {
CounterView(viewModel: CounterViewModel(initialValue: number))
}
.task {
while true {
try! await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
number = Int.random(in: 0..<100)
}
}
}
}

struct CounterView: View {
@ObservedObject private var viewModel: CounterViewModel

init(viewModel: CounterViewModel) {
self.viewModel = viewModel
}

var body: some View {
VStack {
Button("Increment", action: viewModel.increment)
Text("Number is \(viewModel.count)")
Button("Decrement", action: viewModel.decrement)
}
}
}

class CounterViewModel: ObservableObject {
@Published private(set) var count = 0

init(initialValue: Int) {
count = initialValue
}

func increment() {
count += 1
}

func decrement() {
count -= 1
}
}

Of course, that code results in the wrong behavior as we can see in the video.

As you might have guessed, the underlying issue is that the CounterView, together with its CounterViewModel, are being recreated…

The first version of SwiftUI came with a simple way of observing changes to an external piece of state. @ObservedObject was not opinionated about how to handle the underlying lifecycle of the observed object, it was up to us.

In simple scenarios, we could have a single ObservableObject, inject it as an environment object at the root node of your application, and access it in any children node. It was simple, but depending on the scale and the complexity of the app, it might not be the best solution.

If SwiftUI doesn’t handle the lifecycle of our view models, we should come up with a way to do so. A common solution would be to delegate that object lifecycle responsibility to some sort of dependency container (a favorite of mine is Factory) and leverage scopes to fix our issue.

import Factory

extension Container {
func counterViewModel(initialValue: Int) -> Factory<CounterViewModel> {
self { CounterViewModel(initialValue: initialValue) }.shared
}
}

// In ContentView...
CounterView(viewModel: Container.shared.counterViewModel(initialValue: number)())

In this case, by using the shared scope, the same view model is reused when the view tries to create a new one. Once the view is dismissed, the underlying view model is destroyed as well, creating a new one the next time we present the screen.

Even if we have managed to reuse the very same view model, view models are still eager. Even if SwiftUI hasn’t created/shown the real view yet, the fact that the view description has been created forces our view model to be created… (NavigationLink is well known for its eager behavior). This means that patterns like RAII or attaching subscriptions to view model initialization could not be the best idea in SwiftUI, and maybe, only maybe, onAppear and similar methods are a better approach… Well, iOS 14 and @StateObject changed that.

@StateObject and iOS 14 to the rescue

Fortunately, iOS 14 and @StateObject allowed to sync the lifecycle of our view models to the lifecycle of our real views.

  • Real views are reusing the correct view model, no matter how many times we create new view descriptions and instantiate new view models.
  • View models are created lazily when the real view is created, not when the view description is created.
  • View models are destroyed when the real view is destroyed.

Replacing our previous @ObservedObject with @StateObject is simple.

struct CounterView: View {
@StateObject private var viewModel: CounterViewModel

init(viewModel: CounterViewModel) {
_viewModel = .init(wrappedValue: viewModel)
}


}

That change makes the app behave correctly. Even if the CounterView is re-created each time (alongside its view model), the new view model instance will only be used as the initial state, in case SwiftUI decides to remove the counter view completely from the hierarchy and show it again.

But isn’t it strange that we have to recreate the view model each time? As we previously mentioned, we could have code on init and deinit that could potentially be problematic if we run it multiple times… Yes, it’s generally a good idea to do few things on initializers, but sometimes it’s simply convenient to clean things up when objects are destroyed. The problem in this case is the place where we decided to create the view model. A subtle change fixes the issue.

struct CounterView: View {
@StateObject private var viewModel: CounterViewModel

init(initialValue: Int) {
_viewModel = .init(wrappedValue: CounterViewModel(initialValue: initialValue))
}


}

Instead of injecting the view model into the CounterView, we simply pass the initial value and let the view create the view model. The trick is the wrappedValue parameter, which is a function, disguised by an @autoclosure.

init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)

That way, SwiftUI manages to only call that function to create the view model the first time the real view is created, and never the following times where the view description is re-created.

Having that function also gives us that lazy behavior for view models we mentioned previously. Let’s make a tiny change in our view layer to show the counter view navigated.

NavigationLink("Show counter with value: \(number)") {
CounterView(initialValue: number)
}

NavigationLink creates the underlying CounterView eagerly. That means that any view model we need to create CounterView would also be created eagerly. Fortunately, @StateObject works perfectly for this case, creating the view model lazily (via the @autoclosure function we mentioned earlier) at the exact moment when the real view is presented on the screen.

@StateObject is quite easy to misuse though. Unless we use the correct initializer and have the view model instantiation inside the @autoclosure, we’ll lose all those nice benefits of not creating the view models multiple times and have them created lazily. But if done correctly, it provides a perfect way so views can manage the underlying lifecycle of their view models.

At this point. I was fully convinced. Relying on initialization and deinitialization for subscriptions and unsubscriptions and implementing the RAII pattern this way was still feasible in SwiftUI. Not only feasible. It was a good idea!

Well, I’m no longer sure.

iOS 17 and the @Observable macro

iOS 17 changed everything with the new @Observable macro. It not only decoupled SwiftUI from Combine, but it also made SwiftUI far more efficient about when to update views and when not, depending on the underlying state that is read in the body property.

Let’s blindly follow Apple’s guide to migrate from the old Combine world to the new shiny @Observable world.

@Observable class CounterViewModel {
private(set) var count = 0

}

struct CounterView: View {
@State private var viewModel: CounterViewModel

init(initialValue: Int) {
viewModel = CounterViewModel(initialValue: initialValue)
}

}

Changes are minimal. CounterViewModel no longer needs to be an ObservableObject and can simply adopt the new @Observable macro syntax. Then, we no longer need to use @StateObject and we can use @State, which is freeing to use regardless of the nature of the object type (whether we use values or reference types). @State still makes sure that the proper view model is always used by the view.

It seems like a naive change. Out of the blue though, we’ve lost two important things we had with @StateObject.

  1. The view model is created multiple times now, each time the ContentView view is recomputed.
  2. The view model is no longer created lazily.

In fact, we’ve lost another thing… The fact that the view model’s deinit is never called. An important thing indeed! At the moment I’m writing this article, with Xcode 15.0, all @State properties are leaked. Fortunately, this issue has been fixed in Xcode 15.2 beta.

As we did with @ObservedObject, let’s finally use the dependency container approach to fix both issues, the fact that we are creating multiple view models, eagerly.

To avoid creating multiple view models, we’ll have the very same code we previously had, by leveraging the shared scope to reuse the previously in-memory view model. As for the lazy part, we’ll simply have a function as a way to obtain our view model:

import Factory

extension Container {
func counterViewModel(initialValue: Int) -> Factory<CounterViewModel> {
self { CounterViewModel(initialValue: initialValue) }.shared
}
}

struct CounterView: View {
private let viewModel: () -> CounterViewModel

init(initialValue: Int) {
viewModel = { Container.shared.counterViewModel(initialValue: initialValue)() }
}


}

Conclusion

I guess the conclusion to all this might be that we shouldn’t rely on controlling the view model’s lifecycle.

While @StateObject seemed like the perfect way to rely back on proper init and deinit behavior (at least the proper way we were used to with UIKit), that behavior has been broken with the new @Observable macro approach. Yes, we can use external dependency containers to fix all these issues, but those aren’t free and can be hard to maintain and error-prone (not to mention that they feel like workarounds to me).

Relying on the implicit call of init and deinit might be a bad practice we should avoid with SwiftUI. Instead, I think a safer approach is to explicitly rely on lifecycle methods that SwiftUI views give us (whether those are onAppear, onDisappear, or different ones).

But that’s only my opinion, at least for now. In any case, I’m still confused. Who knows, maybe iOS 18 will change my mind, again.

--

--

Luis Recuenco
The Swift Cooperative

iOS at @openbank_es. Former iOS at @onuniverse, @jobandtalent_es, @plex and @tuenti. Creator of @ishowsapp.