Lazy Initialization @State in SwiftUI: Overcoming Premature Object Creation

Published on

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.

Swift
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:

Swift
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:

Swift
@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:

Swift
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:

Swift
@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.

Swift
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.

Weekly Swift & SwiftUI highlights!