Matthew's Dev Blog

Investigation of @StateObject lifecycle

Why do this?

@StateObject feels like magic - and it's easy to understand for simple use-cases. The standard explanation goes like this:

Use @StateObject when your SwiftUI view needs ownership of an observable object, and you want to keep the same instance of the object while its owning view is alive. Use @ObservedObject for observable objects who are owned by another view.

But when something is "magic", I worry that I might accidentally be creating the wrong number of objects. So this blogpost is an exploration of @StateObject behaviour.

First experiment: a simple example

Take this simplest example of @StateObject and @ObservedObject:

class ViewModel: ObservableObject {
	var objectId: String {
		ObjectIdentifier(self).debugDescription
	}
}

struct ParentView: View {
	@StateObject var viewModel = ViewModel()

	var body: some View {
		VStack {
			Text("Parent \(self.viewModel.objectId)")
			ChildView(viewModel: self.viewModel)
		}
	}
}

struct ChildView: View {
	@ObservedObject var viewModel: ViewModel

	var body: some View {
		Text("Child \(self.viewModel.objectId)")
	}
}

Here, I'm using ObjectIdentifier to find the memory location for the ViewModel object, and displaying that location in both ParentView and ChildView. ParentView creates a ViewModel using @StateObject and passes it to ChildView, who observes it using @ObservedObject.

If you run this, you'll see that both ParentView and ChildView show the same memory address - so the ViewModel is indeed shared by both views.

Let's explore further.

Second experiment: multiple instances in the view hierarchy

class ViewModel: ObservableObject {
	var objectId: String {
		ObjectIdentifier(self).debugDescription
	}
}

struct ParentView: View {
	var body: some View {
		VStack {
			ChildView()
			ChildView()
		}
	}
}

struct ChildView: View {
	@StateObject var viewModel = ViewModel()

	var body: some View {
		Text("Child \(self.viewModel.objectId)")
	}
}

Here, ChildView owns a ViewModel which is created using @StateObject, and our ParentView has two children.

As you'd expect, each child gets its own instance of ViewModel.

This is consistent with Apple's documentation for @StateObject:

SwiftUI creates a new instance of the object only once for each instance of the structure that declares the object.

ChildView struct appears twice in the view hierarchy. Two instances of ChildView means two instances of ViewModel.

But what if we create multiple ChildView views in a loop?

Third experiment: multiple instances in a loop

class ViewModel: ObservableObject {
	var objectId: String {
		ObjectIdentifier(self).debugDescription
	}
}

struct ParentView: View {
	var body: some View {
		VStack {
			ForEach(0..<4, id: \.self) { _ in
				ChildView()
			}
		}
	}
}

struct ChildView: View {
	@StateObject var viewModel = ViewModel()

	var body: some View {
		Text("Child \(self.viewModel.objectId)")
	}
}

Here, ChildView is created in a loop... so it only appears once in the code. But at runtime, there are four instances of ChildView. How many ViewModels - one or four?

Well, there are four instances of ViewModel too. Interesting.

Let's look back at that Apple documentation:

SwiftUI creates a new instance of the object only once for each instance of the structure that declares the object.

I've highlighted "instance" there. A SwiftUI View isn't like a UIKit UIView. When we write a UIView, that object is instantiated at runtime. But in SwiftUI, a View is a description of what the UI should look like. It's not the runtime UI.

Fourth experiment - creating and destroying views

What happens when we create and destroy views which own @StateObject objects?

class ViewModel: ObservableObject {
	var objectId: String {
		ObjectIdentifier(self).debugDescription
	}

	init() {
		print("-> ViewModel init")
	}

	deinit {
		print("-> ViewModel deinit")
	}
}

struct ParentView: View {
	@State var childCount = 0

	var body: some View {
		VStack {
			Button {
				if self.childCount == 0 {
					childCount = 4
				} else {
					childCount = 0
				}
			} label: {
				Text("Tap me")
			}

			ForEach(0..<self.childCount, id: \.self) { _ in
				ChildView()
			}
		}
	}
}

struct ChildView: View {
	@StateObject var viewModel = ViewModel()

	var body: some View {
		Text("Child \(self.viewModel.objectId)")
	}
}

Here, I'm using a button to adjust the number of ChildView instances that are displayed, toggling the number between 0 and 4.

Each time the four ChildView instances are displayed, they get a new set of ViewModel objects - ViewModels are not reused, because the owning view instance has been removed from the runtime view hierarchy. When the ChildViews are added back into the view hierarchy, they are new instances, so they get new ViewModels.

I've added some debugging into ViewModel.init() and ViewModel.deinit - and we can see that the ViewModels are deallocated when the four ChildView instances are removed.

So when are @StateObject objects created? And how can they be created multiple times?

Another interesting observation: -> ViewModel init is only printed when the four ChildView instances are presented on-screen, not when the app first starts. But looking at the code, it appears that a ViewModel instance should be created whenever the SwiftUI view structs are evaluated.

The clue is in the initializer for the StateObject property wrapper:

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

Yeah - it's an escaping autoclosure. So when we write this:

@StateObject var viewModel = ViewModel()

Then ViewModel() is wrapped inside a closure. StateObject property wrapper stores this closure, and calls it whenever a new ViewModel is needed. Nice.

Fifth experiment: when the owner is the detail in a NavigationView

class ViewModel: ObservableObject {
	var objectId: String {
		ObjectIdentifier(self).debugDescription
	}

	init() {
		print("-> ViewModel init")
	}

	deinit {
		print("-> ViewModel deinit")
	}
}

struct MainView: View {
	var body: some View {
		NavigationView {
			List(0..<10, id: \.self) { index in
				NavigationLink {
					DetailView()
				} label: {
					Text("\(index)")
				}
			}
		}
	}
}

struct DetailView: View {
	@StateObject var viewModel = ViewModel()

	var body: some View {
		Text("Detail \(self.viewModel.objectId)")
	}
}

This works exactly as you'd expect:

  • selecting a cell in the primary view creates a new DetailView
  • when DetailView is presented, a new ViewModel is created
  • selecting a different cell causes the previous ViewModel to be deallocated, as the previous DetailView is destroyed
  • selecting a cell which is already selected has no effect; the exising ViewModel for the presented DetailView remains
Tagged with:

First published 3 January 2022