Black Friday 2025 deal: 30% off our Swift and SwiftUI books! Learn more ...Black Friday 2025 deal:30% off our Swift and SwiftUI books >>

Initializing @Observable classes within the SwiftUI hierarchy

I've noticed that many iOS developers, even those who have been working with SwiftUI for a while, still have questions about how @Observable classes work with SwiftUI views, how they should be initialized, and whether we need to store our observable models in @State. In this post, we'll look at some examples of managing observable state in SwiftUI, explore the recommended approaches, and see what can go wrong if our observable classes are not stored correctly.

# Storing @Observable models in @State

When we initialize an @Observable class in a SwiftUI view and read its properties, SwiftUI will automatically refresh any views that depend on those properties when they change. This happens even if we simply assign the observable class to a stored property in the view struct. Because of this, it may not be immediately obvious why we need to bother with storing the model in @State.

In the following example, the text view will update as we increment the count property of DataModel.

@Observable
class DataModel {
    var count = 0
}

struct ContentView: View {
    private let dataModel: DataModel = DataModel()
    
    var body: some View {
        VStack(spacing: 30) {
            Text("Count: \(dataModel.count)")
                .font(.title)
            
            Button("Increment") {
                dataModel.count += 1
            }
            .font(.title2)
            .buttonStyle(.borderedProminent)
        }
    }
}
Side by side iPhone screens showing the same view before and after tapping Increment, with the count changing from 0 to 5

However, when an @Observable model is not stored in @State, it's not tied to the SwiftUI view lifecycle. A new instance will be created every time the view struct is initialized. In SwiftUI, view structs are ephemeral and only exist long enough to describe the view hierarchy. The actual views and their state are managed by SwiftUI in an internal data structure, so the lifetime of a view struct is not the same as the lifetime of the on screen view.

We can see the issue with not assigning the observable model to @State in the next example. Here we refactor the counter into a separate CountView and add a ColorPicker to ContentView, which controls the tint color of the app.

struct ContentView: View {
    @State private var tint: Color = Color.accentColor
    
    var body: some View {
        NavigationStack {
            CountView()
                .tint(tint)
                .toolbar {
                    ColorPicker("Tint Color", selection: $tint)
                        .labelsHidden()
                }
        }
    }
}

struct CountView: View {
    private let dataModel = DataModel()
    
    var body: some View {
        VStack(spacing: 30) {
            Text("Count: \(dataModel.count)")
                .font(.title)
            
            Button("Increment") {
                dataModel.count += 1
            }
            .font(.title2)
            .buttonStyle(.borderedProminent)
        }
    }
}

Every time the tint color changes, ContentView's body is rebuilt, the CountView initializer runs again, and DataModel is recreated, losing its previous state.

Side by side iPhone screens showing the counter before and after changing the tint color, with the count resetting from 5 to 0

Simply changing the dataModel property in CountView from a stored let on the view struct to a variable annotated with @State fixes the issue. The @State annotation tells SwiftUI that this property is tied to the view lifecycle, so SwiftUI manages its storage on our behalf and keeps it alive while the view is present in the hierarchy.

struct CountView: View {
    @State private var dataModel = DataModel()
    
    var body: some View {
        VStack(spacing: 30) {
            Text("Count: \(dataModel.count)")
                .font(.title)
            
            Button("Increment") {
                dataModel.count += 1
            }
            .font(.title2)
            .buttonStyle(.borderedProminent)
        }
    }
}

The state persists even though the view struct itself may be created and discarded many times during that period.

Side by side iPhone screens showing the counter before and after changing the tint color, with the count remaining at 5

# Deferring Observable model initialization

One caveat to remember when deciding when and where to perform the observable class initialization is that, even though the state of the model will persist across recreations of the view struct when it is initialized as the default value of a state property, the initializer for the model will still be called every time the view initializer runs.

Let's add a print statement inside the DataModel initializer to observe what happens.

@Observable
class DataModel {
    var count = 0
    
    init() {
        print("DataModel initialized")
    }
}

struct ContentView: View {
    @State private var tint: Color = Color.accentColor
    
    var body: some View {
        NavigationStack {
            CountView()
                .tint(tint)
            
            ...
        }
    }
}

struct CountView: View {
    @State private var dataModel = DataModel()
    
    var body: some View {
        VStack(spacing: 30) {
            Text("Count: \(dataModel.count)")
                .font(.title)
            
            ...
        }
    }
}

When the tint color changes, ContentView’s body runs again, the CountView initializer is called, and "DataModel initialized" is printed to the console, even though we can see in the UI that the state of dataModel is preserved. SwiftUI does not overwrite the existing state with a new DataModel instance on each rebuild, but it still evaluates the initializer expression, so the print runs every time.

This aspect is important to keep in mind, because any heavy logic inside the @Observable class initializer may run multiple times and potentially degrade app performance, since SwiftUI relies on view initializers being very cheap.

In cases where our observable class initializer logic is not quick and simple, we can defer creating the model by moving that work into a task modifier.

struct CountView: View {
    @State private var dataModel: DataModel?
    
    var body: some View {
        VStack(spacing: 30) {
            Text("Count: \(dataModel?.count, default: "0")")
                .font(.title)
            
            Button("Increment") {
                dataModel?.count += 1
            }
            .font(.title2)
            .buttonStyle(.borderedProminent)
        }
        .task {
            dataModel = DataModel()
        }
    }
}

In this setup, the DataModel initializer will run only once, when CountView is added to the hierarchy.

Deferring observable model creation would also let us configure it based on a value passed into the view. But it's important to use the task() modifier with the id parameter, so that the task runs again when that value changes.

@Observable
class DataModel {
    var text: String
    
    init(id: UUID) {
        text = "ID: \(id)"
        print("DataModel initialized for id: \(id)")
    }
}

struct ContentView: View {
    @State private var id = UUID()
    
    var body: some View {
        NavigationStack {
            IDView(id: id)
                .toolbar {
                    Button(
                        "Reset ID",
                        systemImage: "arrow.trianglehead.2.clockwise"
                    ) {
                        id = UUID()
                    }
                }
        }
    }
}

struct IDView: View {
    let id: UUID
    @State private var dataModel: DataModel?
    
    var body: some View {
        Text(dataModel?.text ?? "")
            .task(id: id) {
                dataModel = DataModel(id: id)
            }
    }
}

In a real-world app, this pattern can be useful when we need to load different data based on the current selection or other changing state.

# Initializing observable state in the App struct

It's important to remember that state initialized in views inside a WindowGroup scene is scoped to that scene. On devices that support multiple scenes, such as iPad or Mac, each window gets its own fresh copy of that state. If we want shared state across all scenes, we can initialize our observable model in the App struct and then pass it down through the environment.

@Observable
class DataModel {
    var count = 0
}

@main
struct ObservableExampleApp: App {
    @State private var dataModel = DataModel()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(dataModel)
        }
    }
}

struct ContentView: View {
    var body: some View {
        CountView()
    }
}

struct CountView: View {
    @Environment(DataModel.self)
    private var dataModel
    
    var body: some View {
        VStack(spacing: 30) {
            Text("Count: \(dataModel.count)")
                .font(.title)
            
            Button("Increment") {
                dataModel.count += 1
            }
            .font(.title2)
            .buttonStyle(.borderedProminent)
        }
    }
}

This way, each scene will read and write to the same shared state. This is a good choice for application wide data, but not for state that should be scene specific, such as selection or navigation.

Side by side app windows on an iPad, each showing Count 5, demonstrating shared state across scenes

Even though the App struct initializer should only run once per application lifecycle, it's best to still annotate the observable model with @State or, at least make it a singleton with a static declaration, to be 100% certain that it will not get reset in any unexpected scenario.

If we want to defer the initialization of the application wide state, we cannot simply assign it in a task() modifier like we did for view state. A task attached to the root view will run once per scene, not once per app launch. To ensure the state is initialized only once, we would need an additional flag or some other guard around the initialization.


To learn more about @Observable in SwiftUI, including when to annotate it with @Bindable and how it differs from ObservableObject, you can read my other post: Using @Observable in SwiftUI views.


If you are looking to build a strong foundation in SwiftUI, my book SwiftUI Fundamentals is a great place to start. It takes a deep dive into the framework's core principles and APIs to help you understand how it works under the hood and how to use it effectively in your projects.

For more resources on Swift and SwiftUI, check out my other books and book bundles.

I’m currently running a Black Friday 2025 promotion with 30% off my books, plus additional savings when purchased as a bundle. Visit the books page to learn more.

SwiftUI Fundamentals by Natalia Panferova book coverSwiftUI Fundamentals by Natalia Panferova book cover

Black Friday 2025 offer: 30% off!$35$25

The essential guide to SwiftUI core concepts and APIs

SwiftUI Fundamentalsby Natalia Panferova

  • Explore the key APIs and design patterns that form the foundation of SwiftUI
  • Develop a deep, practical understanding of how SwiftUI works under the hood
  • Learn from a former Apple engineer who worked on widely used SwiftUI APIs
Black Friday 2025 offer: 30% off!

The essential guide to SwiftUI core concepts and APIs

SwiftUI Fundamentals by Natalia Panferova book coverSwiftUI Fundamentals by Natalia Panferova book cover

SwiftUI Fundamentals

by Natalia Panferova

$35$25