The Observation framework has brought native property-level observation to Swift, effectively preventing unnecessary view updates in SwiftUI triggered by unrelated property changes, thereby enhancing application performance. However, since @State
does not offer a lazy initialization constructor like @StateObject
, it may lead to performance degradation or even logical issues due to the premature construction of instances in certain scenarios. This article explores how to implement a lazy initialization solution for Observable instances using @State
.
Problem Demonstration
In SwiftUI, the creation of view instances does not necessarily coincide with their insertion into the view hierarchy. In many cases, view instances may be created prematurely or multiple times. For example, in the code below, even if you have not navigated to the next layer of the navigation container (i.e. LinkViewUsingObservation
), SwiftUI will still construct the observable instance TestObject
in that view ahead of time.
import Observation
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationStack {
NavigationLink {
LinkViewUsingObservation()
} label: {
Text("Hello")
}
}
}
}
struct LinkViewUsingObservation: View {
@State var object = TestObject()
var body: some View {
Text("State Observation")
}
}
@Observable
class TestObject {
init() {
print("init")
}
}
As you can see, the NavigationLink
triggers the premature creation of the LinkViewUsingObservation
instance. Imagine using a List
to display a large number of LinkViewUsingObservation
views; such early construction would inevitably lead to performance issues.
If you switch the implementation to use ObservableObject
, the premature construction problem does not occur because TestObject
is only instantiated after navigating into the LinkViewUsingStateObject
view:
struct ContentView: View {
var body: some View {
NavigationStack {
NavigationLink {
LinkViewUsingStateObject()
} label: {
Text("Hello")
}
}
}
}
struct LinkViewUsingStateObject: View {
@StateObject var object = TestObject()
var body: some View {
Text("StateObject")
}
}
class TestObject: ObservableObject {
init() {
print("init")
}
}
The Lazy Initialization Mechanism of StateObject
The reason StateObject
does not immediately construct TestObject
upon view instance creation is because it employs a lazy loading strategy. Its initializer is defined as follows:
@inlinable nonisolated public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)
Only when the view is actually loaded does StateObject
call the thunk
closure to create and retain the ObservableObject
instance, thereby avoiding unnecessary early construction. You can find a detailed explanation of its lazy initialization implementation in the How to Avoid Repeating SwiftUI View Updates article.
However, when the original ObservableObject
implementation is replaced with Observable
, developers lose the benefit of lazy instantiation since @State
does not offer a similar lazy loading mechanism.
An Effective but Unelegant Workaround
A simpler workaround is to have the Observable
instance also conform to the ObservableObject
protocol and continue using @StateObject
for declaration. This approach retains the lazy loading feature while allowing the view to respond to property changes, thus avoiding unnecessary updates:
struct LinkViewUsingStateObject: View {
@StateObject var object = TestObject() // Declare using StateObject
var body: some View {
let _ = print("update")
Text("StateObject")
Text(object.name)
Button("Change Name") {
object.name = "\(Int.random(in: 0...1000))"
}
Button("Change Age") {
object.age = Int.random(in: 0...100)
}
}
}
@Observable
class TestObject: ObservableObject { // Conform to ObservableObject
init() {
print("init")
}
var name = "abc"
var age = 10
}
However, this approach can lead to confusion—especially in team environments—where it becomes unclear which observation mechanism is being used.
@LazyState: A @State Implementation Supporting Lazy Initialization
To address this issue, many developers have already provided feedback to Apple, hoping for a lazy loading mechanism to be added to @State
in the future. In the meantime, we can implement this feature using a custom property wrapper:
@MainActor // Ensure the property wrapper operates on the main thread to complete setup before accessing wrappedValue
@propertyWrapper
public struct LazyState<T: Observable>: @preconcurrency DynamicProperty { // Restricted to use with Observable types
@State private var holder: Holder
// To maintain consistency with State and StateObject, the instance is created only once and is immutable (no setter provided)
public var wrappedValue: T {
holder.wrappedValue
}
public var projectedValue: Binding<T> {
// Since modifications are only done via key paths, the setter is ignored.
return Binding(get: { wrappedValue }, set: { _ in })
}
// Called when the view loads, to create the instance.
public func update() {
guard !holder.onAppear else { return }
holder.setup()
}
public init(wrappedValue thunk: @autoclosure @escaping () -> T) {
_holder = State(wrappedValue: Holder(wrappedValue: thunk()))
}
}
extension LazyState {
// Helper class to hold the instance.
final class Holder {
private var object: T!
private let thunk: () -> T
// Flag to mark whether the instance has been initialized, to avoid duplicate creations.
var onAppear = false
var wrappedValue: T {
object
}
func setup() {
object = thunk() // Lazily initialize the instance.
onAppear = true // Mark as initialized to prevent further calls.
}
init(wrappedValue thunk: @autoclosure @escaping () -> T) {
self.thunk = thunk
}
}
}
Now you can declare Observable instances using @LazyState
. When Apple eventually enhances @State
, you can easily switch back to the standard implementation.
struct ContentView: View {
var body: some View {
NavigationStack {
NavigationLink {
LinkViewUsingLazyState()
} label: {
Text("Hello")
}
}
}
}
struct LinkViewUsingLazyState: View {
@LazyState var object = TestObject()
var body: some View {
Text("LazyState")
}
}
@Observable
class TestObject {
init() {
print("init")
}
}
Conclusion
The Observation framework significantly enhances SwiftUI performance, but due to changes in its underlying implementation, developers must adjust their projects accordingly. We look forward to Apple introducing a lazy loading mechanism for @State
soon, which would naturally resolve this issue. Until then, the custom @LazyState
implementation offers an effective, albeit slightly inelegant, solution.