5.3 — State management

Opening scenario

A SwiftUI app has a checkout flow: cart screen → shipping → payment → confirmation. State is scattered:

  • The cart total is computed in the cart screen and recomputed in the confirmation screen
  • The shipping address is stored in @State in the shipping screen and disappears when the user navigates away
  • The payment screen reads the cart from UserDefaults for some reason
  • The total amount is wrong on the confirmation screen because the discount applied at payment never propagates back

This is a state ownership bug. SwiftUI gives you five tools — @State, @Binding, @StateObject, @ObservedObject, @EnvironmentObject — for five different ownership situations. Misusing them turns straightforward features into “why is this empty/stale/duplicated?” bugs.

This chapter teaches the taxonomy. Next chapter (@Observable) modernizes the syntax for Swift 6.

Property wrapperOwned byUse when
@StateThe view itselfPrivate, view-local state (toggles, text input, scroll position)
@BindingA parent viewA child needs read/write access to parent’s state
@StateObjectThe view itself (single instance)The view creates and owns a reference-type observable
@ObservedObjectAn ancestor or externalThe view receives a reference-type observable
@EnvironmentObjectAnywhere in the hierarchyMany views need access without prop-drilling

Concept → Why → How → Code

@State — view-private value-typed state

struct ToggleDemo: View {
    @State private var isOn = false

    var body: some View {
        Toggle("Notifications", isOn: $isOn)
    }
}
  • SwiftUI stores isOn outside the struct (because the struct is recreated constantly)
  • The view reads it; mutations trigger re-render
  • $isOn produces a Binding<Bool> — a two-way handle to the storage
  • Always private@State is for this view only. If a parent needs it, lift it up.
  • Works for Int, String, Bool, structs, arrays, enums — any value type

When SwiftUI initializes the view, it sees @State for the first time and allocates persistent storage. On subsequent recreations of the view struct (which happen constantly), SwiftUI reuses the same storage.

@Binding — pass-through to someone else’s state

struct ParentView: View {
    @State private var text = ""

    var body: some View {
        VStack {
            ChildField(text: $text)
            Text("You typed: \(text)")
        }
    }
}

struct ChildField: View {
    @Binding var text: String

    var body: some View {
        TextField("Name", text: $text)
    }
}
  • ChildField doesn’t own the state; it has a two-way binding into the parent’s @State
  • $text (parent) unwraps @State to Binding<String>; passes to @Binding (child)
  • Inside the child, $text extracts the same Binding<String> for further pass-through
  • Mutating text in the child writes through to the parent’s storage; parent re-renders too

Use @Binding when a child needs to mutate parent-owned state. It’s the SwiftUI equivalent of inout for views.

@StateObject — view owns a reference-type observable

When state outgrows a single property and needs methods, computed properties, or coordination with services, you reach for a class:

@MainActor
final class SearchModel: ObservableObject {
    @Published var query = ""
    @Published private(set) var results: [Item] = []

    func search() async { /* ... */ }
}

struct SearchView: View {
    @StateObject private var model = SearchModel()

    var body: some View {
        VStack {
            TextField("Search", text: $model.query)
            List(model.results) { item in Text(item.title) }
        }
        .task { await model.search() }
    }
}
  • @StateObject runs the initializer once for the view’s lifetime
  • Survives view struct recreation
  • Re-renders when any @Published property changes (or objectWillChange.send() is called)
  • Only initialize with a fresh instance@StateObject var x = ParentDependency.shared works but is usually a smell

The most common bug: initializing @StateObject from a parent-provided value:

struct UserScreen: View {
    let userID: String
    @StateObject var model: UserModel    // ❌ STALE

    init(userID: String) {
        self.userID = userID
        _model = StateObject(wrappedValue: UserModel(userID: userID))
    }
}

StateObject(wrappedValue:) takes an @autoclosure — but SwiftUI only evaluates it the first time the view is initialized. If userID changes later, the model still holds the old value. The fix: use @ObservedObject with parent-owned storage, or use .id(userID) to force a new identity (which recreates the StateObject).

@ObservedObject — view observes a reference-type observable owned elsewhere

struct CartItemRow: View {
    @ObservedObject var cart: Cart      // injected, owned by parent
    let item: CartItem

    var body: some View {
        HStack {
            Text(item.name)
            Spacer()
            Button("Remove") { cart.remove(item) }
        }
    }
}
  • View does not own the object’s lifecycle
  • View re-renders when the observed object’s @Published properties change
  • Lifetime is the parent’s problem
  • Don’t use @ObservedObject for a view-owned model — every parent re-render creates a new instance

Rule of thumb: if you write @ObservedObject var x = SomeModel() you almost certainly meant @StateObject.

@EnvironmentObject — implicit dependency injection

For shared services or top-level state, prop-drilling through 5 views is tedious:

@MainActor
final class AuthService: ObservableObject {
    @Published var currentUser: User?
}

// Inject at the root
@main
struct MyApp: App {
    @StateObject private var auth = AuthService()
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(auth)
        }
    }
}

// Read anywhere in the subtree
struct ProfileView: View {
    @EnvironmentObject var auth: AuthService

    var body: some View {
        if let user = auth.currentUser {
            Text("Hi, \(user.name)")
        }
    }
}
  • Looks up the object by type in the environment
  • If absent at runtime, crashes — important to test the injection
  • Subtree-scoped: an .environmentObject(_:) modifier propagates downward only
  • Prefer over deep prop-drilling, but don’t over-globalize (auth, theme, feature flags — sure; “the current cart” — maybe just pass it)

@Environment — typed environment values (different beast)

Confusingly named, but different:

struct MyView: View {
    @Environment(\.colorScheme) var colorScheme
    @Environment(\.dismiss) var dismiss
    @Environment(\.scenePhase) var scenePhase
    @Environment(\.locale) var locale

    var body: some View {
        Button("Close") { dismiss() }
    }
}

@Environment(\.keyPath) reads values from EnvironmentValues (system-provided like colorScheme, dismiss, scenePhase; or your own via EnvironmentKey). Not for arbitrary objects — for that use @EnvironmentObject (or @Environment(ObservableType.self) in iOS 17+ @Observable world; next chapter).

Putting it together — a real example

@MainActor
final class CheckoutFlow: ObservableObject {
    @Published var cart: Cart = Cart()
    @Published var shippingAddress: Address?
    @Published var paymentMethod: PaymentMethod?
    @Published var discount: Discount?

    var total: Decimal { cart.subtotal - (discount?.amount ?? 0) }
}

@main
struct ShopApp: App {
    @StateObject private var checkout = CheckoutFlow()
    var body: some Scene {
        WindowGroup {
            NavigationStack { CartScreen() }
                .environmentObject(checkout)
        }
    }
}

struct CartScreen: View {
    @EnvironmentObject var checkout: CheckoutFlow
    var body: some View {
        VStack {
            ForEach(checkout.cart.items) { item in Text(item.name) }
            NavigationLink("Continue", value: "shipping")
        }
        .navigationDestination(for: String.self) { _ in ShippingScreen() }
    }
}

struct ShippingScreen: View {
    @EnvironmentObject var checkout: CheckoutFlow
    @State private var streetField = ""    // local — just for input

    var body: some View {
        Form {
            TextField("Street", text: $streetField)
            Button("Save") {
                checkout.shippingAddress = Address(street: streetField)
            }
            NavigationLink("Pay", value: "payment")
        }
        .navigationDestination(for: String.self) { _ in PaymentScreen() }
    }
}

// PaymentScreen + ConfirmationScreen all read from `checkout`
// Total is always consistent — single source of truth

Notice the discipline:

  • One owner (@StateObject at the app root)
  • Shared via environment to all flow screens
  • Local input state stays @State
  • No @ObservedObject anywhere — there’s nothing to observe that the view created itself
  • Mutations happen explicitly via methods or direct property writes; the published properties propagate everywhere

State lifecycle — what lives, what dies

StateLives acrossDies on
@StateView identity unchangedIdentity change, or view leaves hierarchy
@StateObjectView identity unchangedIdentity change, or view leaves hierarchy
@ObservedObjectThe object’s own lifetimeObject deallocated externally
@EnvironmentObjectLifetime of whoever called .environmentObject(_:)Container scene/view dies

If a screen loses scroll position or animation state on a parent re-render, check view identity (chapter 5.2) and that no parent re-renders are forcing new identity.

Threading

  • All view updates happen on the main thread
  • @MainActor annotate your observable classes (Swift 6 enforces this)
  • Mutating @Published properties off main → warning (“Publishing changes from background threads is not allowed”)
  • Use await MainActor.run { ... } or Task { @MainActor in ... } to hop
@MainActor
final class Model: ObservableObject {
    @Published var items: [Item] = []

    func load() {
        Task {
            let fetched = try await api.fetch()   // off main
            // back on main: @MainActor isolates the class
            self.items = fetched
        }
    }
}

In the wild

  • Apple’s Reminders app uses a single root observable (the data store) injected via environment; per-screen @State for ephemeral input.
  • Apollo (RIP) famously had a deep @EnvironmentObject graph — auth, theme, settings, network status — visible in its WWDC talk.
  • Airbnb’s SwiftUI internal apps standardize on “one observable per feature, injected via environment, view-local @State for inputs only.”
  • A Slack-shaped chat app typically has: a MessageStore env object, a Conversation env object (per-conversation), and @State for the compose field.

Common misconceptions

  1. @StateObject and @ObservedObject are interchangeable.” Critical bug source. @ObservedObject does not manage lifetime; if you write @ObservedObject var x = Model() you create a new model every render and lose state.
  2. @EnvironmentObject is global state.” No — it’s scoped to the subtree below the .environmentObject(_:) modifier. Two different subtrees can have two different objects of the same type.
  3. @State works for any property type.” Only for value types. For reference types, use @StateObject. Using @State for a class instance silently breaks observation.
  4. “I should put objectWillChange.send() in every setter.” No — @Published does this for you. Manual sends are for cases where the publishing is conditional or batched.
  5. @Binding and @State produce the same thing when you use $.” Both produce Binding<T>, but the source differs. @State’s binding writes to view-owned storage; @Binding’s binding writes wherever the original @State lives.

Seasoned engineer’s take

State management is where SwiftUI codebases go bad fast. The team-level rules I enforce:

  1. One source of truth per piece of state. If it lives in two places, they will diverge.
  2. State ownership matches scope. Local to a view → @State. Cross-feature → environment. Cross-app → root.
  3. No @StateObject initialized from props. If you need that, you have a design problem (use .id() or pass plain data).
  4. @ObservedObject requires explicit comment explaining what owns the lifetime.
  5. Don’t reach for @EnvironmentObject to avoid passing 2 params. Use it for genuinely cross-cutting concerns.
  6. @AppStorage is for genuine user preferences only — not for state you’d be sad to lose (use Keychain or your data store).

When a SwiftUI codebase shows “the cart goes empty randomly” or “the form clears itself” bugs, 90% of the time it’s @StateObject vs @ObservedObject confusion or unstable view identity.

TIP: When debugging “my view doesn’t update”, check: (1) is the property actually @Published? (2) is the object reference the same one I’m mutating? (3) is body reading the property — if you only read it inside a closure, SwiftUI doesn’t subscribe.

WARNING: Don’t store reference-type objects in @State. SwiftUI uses identity (==) for value types; for classes, it’ll observe the reference not the content. Use @StateObject.

Interview corner

Junior-level: “When do you use @State vs @Binding?”

@State when the view owns the value (private, view-local). @Binding when a child view needs to read and write a parent’s state — the binding is a handle to someone else’s storage. @State declares storage; @Binding references it via $value.

Mid-level: “Explain @StateObject vs @ObservedObject. Walk through a bug each one would cause if misused.”

@StateObject owns the lifetime — the instance is created on first view init and persists across the view struct being recreated. @ObservedObject does not own lifetime — the instance is provided externally and observed for changes.

Bug from misuse:

  • Using @ObservedObject var model = Model() on a view: every parent re-render creates a new Model, losing all state. Symptom: fields clear themselves, lists go empty.
  • Using @StateObject var model = Model(userID: userID) where userID is a parent-provided prop: the Model is initialized once with the original userID and never updates. Symptom: changing users in the parent doesn’t update the child’s data.

Senior-level: “Design the state architecture for a 50-screen e-commerce app. What lives where, and why?”

Layered:

  • App root: @StateObject for AuthService, CartService, ThemeService, FeatureFlagService. Injected as .environmentObject(_:). These are cross-cutting and must be single instances.
  • Feature roots (Checkout, Profile, Browse, Search): each owns a feature-scoped @StateObject (e.g., CheckoutFlow). Injected via environment for that subtree only.
  • Screens: @EnvironmentObject to read shared state; @State for local input (text fields, toggles, animation triggers).
  • Components (small, reusable views): @Binding for parent state; no environment dependence — improves reusability and previewability.
  • Persistence: separate Store layer (SwiftData/Core Data). Observable services subscribe; views never touch persistence directly.

Plus: every observable is @MainActor. Network and data work happens in Task { let result = try await ... } then assigns to @Published on main. Migrating to @Observable (next chapter) when minimum target is iOS 17+.

Red flag in candidates: Defaulting @EnvironmentObject for everything (“it’s simpler”). Indicates no taste for explicit dependencies. Production apps with 20+ env objects become untestable and hard to reason about.

Lab preview

Lab 5.1 exercises @State, @Binding, @StateObject together in a SwiftData-backed todo app — a controlled environment to build muscle memory before reading the next chapter on @Observable.


Next: @Observable & Swift 6