iosswiftswiftui

When should I use @State with an @Observable view model in SwiftUI?


I’m working with the new @Observable macro in SwiftUI (iOS 17+). In some views, my view model works perfectly without wrapping it in @State. But in other views, I must wrap it in @State or updates don’t propagate correctly (like alerts not showing, UI not refreshing, etc.).

Here are two examples that look similar, but behave differently.

struct AddProductView: View {
    private var vm = AddProductVM()
    
    var body: some View {
        @Bindable var dVM = vm
        
        VStack {
            if dVM.shouldShowAlert {
                Text("Alert triggered")
            }
        }
        .onChange(of: dVM.someTrigger) {
            dVM.shouldShowAlert = true
        }
    }
}

This works fine. When someTrigger changes, shouldShowAlert is updated, and the UI reflects it.

❌ Example that requires @State

struct ProductDetailsView: View {
    @State private var vm = ProductDetailsVM() // <-- Required!
    
    var body: some View {
        @Bindable var dVM = vm
        
        VStack {
            if dVM.showErrorAlert {
                Text("Error!")
            }
        }
        .onAppear {
            // Causes the alert to be dismissed unless vm is @State
            dVM.fetchProduct()
        }
    }
}

Without @State, the UI either doesn’t update or resets, especially after .onAppear or .task.

🧠 My Question: • When exactly do I need to wrap an @Observable view model in @State? • What SwiftUI behaviors cause the view to be recreated and thus reinitialize the view model?


Solution

  • When exactly do I need to wrap an @Observable view model in @State?

    The documentation says this clearly:

    Create the source of truth for model data

    To create and store the source of truth for model data, declare a private variable and initialize it with an instance of an observable data model type. Then wrap it with a State property wrapper. For example, the following code stores an instance of the data model type Book in the state variable book:

    struct BookView: View {
        @State private var book = Book()
        
        var body: some View {
            Text(book.title)
        }
    }
    

    By wrapping the book with State, you’re telling SwiftUI to manage the storage of the instance. Each time SwiftUI re-creates BookView, it connects the book variable to the managed instance, providing the view a single source of truth for the model data.

    For "view models", they are typically the "source of truth" for their corresponding view, so they are typically @States in their corresponding view.

    If a property you declare is not supposed to be a source of truth, you should not use @State - e.g. if you expect a parent view to provide a value for that property (in which case the parent view would be the source of truth).

    In both cases you showed, you clearly do not expect AddProductVM to come from somewhere else - i.e. the AddProductView owns the source of truth. The vm property should be a @State in both cases.

    The first case "just so happens" to work because you are lucky, as I will explain below.

    What SwiftUI behaviors cause the view to be recreated and thus reinitialize the view model?

    Wherever you write AddProductView(). But when will AddProductView() get called? This is not possible to answer without a full example, and is an implementation detail anyway. You should not rely on the timing on which View.init is called.

    "Depending on the timing of init" is the key thing that @State helps you avoid. As you have experienced, without @State, the body of the view will be accessing a different object after the view's init has been called a second time. With @State, the subsequently-created Observable objects will be simply discarded, and the body will see the same object.

    Even still, there is still a new object being unnecessarily created and discarded. This is why the documentation for @State recommends initialising Observable objects in task or onAppear:

    struct ContentView: View {
        @State private var library: Library?
    
    
        var body: some View {
            LibraryView(library: library)
                .task {
                    library = Library()
                }
        }
    }