← All topics/UIKit & SwiftUI internals

Question 6

SwiftUI state: @State, @StateObject, and @ObservedObject

When would you use @State vs @ObservedObject vs @StateObject?

Follow-ups

  • What about @Observable on iOS 17 and later?

Answer outline

Each wrapper answers two questions: where the value lives, and who creates it. The three in the question differ in who owns the state:

  1. 1.Local state: @State holds state this view owns, usually a value type. SwiftUI stores it outside the struct, so it survives body recomputation.
  2. 2.Owned object: @StateObject is for the view that creates an ObservableObject. SwiftUI constructs it once for that view's lifetime and keeps it alive across redraws.
  3. 3.Borrowed object: @ObservedObject is for a view that receives an object someone else created. It only observes, so never assign a new instance in init or body.

@Binding gives a child read and write access to a value the parent owns, and @EnvironmentObject reads a shared object injected higher up the tree. On iOS 17 and later, @Observable replaces most ObservableObject wiring, with @State owning the model and @Bindable producing bindings.

Principles

  • Whoever creates the ObservableObject uses @StateObject, and everyone else uses @ObservedObject.
  • @State owns this view's state, and @Binding is the child's handle to a value the parent owns.
  • Keep a single source of truth: the owner holds @State or @StateObject, and nothing else keeps a copy.
  • @ObservedObject never creates the object it observes, because a fresh one would be made each time the parent rebuilds this view.

A counter whose value lives in @State and survives every body recomputation:

@State
struct Counter: View {
    @State private var count = 0
    var body: some View {
        Button("Count: \(count)") { count += 1 }
    }
}

The parent owns isOn and passes $isOn down, which produces a Binding the child can read and write:

@Binding
struct Parent: View {
    @State private var isOn = false
    var body: some View { ToggleRow(isOn: $isOn) }
}

struct ToggleRow: View {
    @Binding var isOn: Bool
    var body: some View { Toggle("On", isOn: $isOn) }
}

Use @StateObject when this view creates the ObservableObject, because SwiftUI constructs it once for the view's lifetime:

@StateObject
struct Root: View {
    @StateObject private var model = ScreenModel()
    var body: some View { Dashboard(model: model) }
}

Use @ObservedObject when another view created the object and passed it in, and never assign a new instance in init or body:

@ObservedObject
struct Dashboard: View {
    @ObservedObject var model: ScreenModel
    var body: some View { Text(model.title) }
}

@EnvironmentObject reads a shared object injected above the subtree with environmentObject(_:), and the app crashes on appear if nothing was injected:

@EnvironmentObject
struct Profile: View {
    @EnvironmentObject private var session: SessionStore
    var body: some View { Text(session.userName) }
}

@Observable (iOS 17 and later) is the macro alternative to ObservableObject and @Published, and @Bindable forms bindings to its properties:

@Observable and @Bindable
@Observable
final class Cart { var items: [Item] = [] }

struct CartView: View {
    @Bindable var cart: Cart
    var body: some View { List(cart.items) { item in Text(item.name) } }
}

Follow-up angles

  • @ObservedObject var model = ScreenModel() builds a new model every time the parent re-creates this view, so @Published fields keep resetting to their initial values.
  • Previews need the same injection as the app, so pass .environmentObject(MockSession()) or the preview crashes on appear.