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
@Statein the shipping screen and disappears when the user navigates away - The payment screen reads the cart from
UserDefaultsfor 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 wrapper | Owned by | Use when |
|---|---|---|
@State | The view itself | Private, view-local state (toggles, text input, scroll position) |
@Binding | A parent view | A child needs read/write access to parent’s state |
@StateObject | The view itself (single instance) | The view creates and owns a reference-type observable |
@ObservedObject | An ancestor or external | The view receives a reference-type observable |
@EnvironmentObject | Anywhere in the hierarchy | Many 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
isOnoutside the struct (because the struct is recreated constantly) - The view reads it; mutations trigger re-render
$isOnproduces aBinding<Bool>— a two-way handle to the storage- Always
private—@Stateis 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)
}
}
ChildFielddoesn’t own the state; it has a two-way binding into the parent’s@State$text(parent) unwraps@StatetoBinding<String>; passes to@Binding(child)- Inside the child,
$textextracts the sameBinding<String>for further pass-through - Mutating
textin 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() }
}
}
@StateObjectruns the initializer once for the view’s lifetime- Survives view struct recreation
- Re-renders when any
@Publishedproperty changes (orobjectWillChange.send()is called) - Only initialize with a fresh instance —
@StateObject var x = ParentDependency.sharedworks 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
@Publishedproperties change - Lifetime is the parent’s problem
- Don’t use
@ObservedObjectfor 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 (
@StateObjectat the app root) - Shared via environment to all flow screens
- Local input state stays
@State - No
@ObservedObjectanywhere — 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
| State | Lives across | Dies on |
|---|---|---|
@State | View identity unchanged | Identity change, or view leaves hierarchy |
@StateObject | View identity unchanged | Identity change, or view leaves hierarchy |
@ObservedObject | The object’s own lifetime | Object deallocated externally |
@EnvironmentObject | Lifetime 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
@MainActorannotate your observable classes (Swift 6 enforces this)- Mutating
@Publishedproperties off main → warning (“Publishing changes from background threads is not allowed”) - Use
await MainActor.run { ... }orTask { @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
@Statefor ephemeral input. - Apollo (RIP) famously had a deep
@EnvironmentObjectgraph — 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
@Statefor inputs only.” - A Slack-shaped chat app typically has: a
MessageStoreenv object, aConversationenv object (per-conversation), and@Statefor the compose field.
Common misconceptions
- “
@StateObjectand@ObservedObjectare interchangeable.” Critical bug source.@ObservedObjectdoes not manage lifetime; if you write@ObservedObject var x = Model()you create a new model every render and lose state. - “
@EnvironmentObjectis 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. - “
@Stateworks for any property type.” Only for value types. For reference types, use@StateObject. Using@Statefor a class instance silently breaks observation. - “I should put
objectWillChange.send()in every setter.” No —@Publisheddoes this for you. Manual sends are for cases where the publishing is conditional or batched. - “
@Bindingand@Stateproduce the same thing when you use$.” Both produceBinding<T>, but the source differs.@State’s binding writes to view-owned storage;@Binding’s binding writes wherever the original@Statelives.
Seasoned engineer’s take
State management is where SwiftUI codebases go bad fast. The team-level rules I enforce:
- One source of truth per piece of state. If it lives in two places, they will diverge.
- State ownership matches scope. Local to a view →
@State. Cross-feature → environment. Cross-app → root. - No
@StateObjectinitialized from props. If you need that, you have a design problem (use.id()or pass plain data). @ObservedObjectrequires explicit comment explaining what owns the lifetime.- Don’t reach for
@EnvironmentObjectto avoid passing 2 params. Use it for genuinely cross-cutting concerns. @AppStorageis 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) isbodyreading 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 newModel, losing all state. Symptom: fields clear themselves, lists go empty. - Using
@StateObject var model = Model(userID: userID)whereuserIDis a parent-provided prop: theModelis initialized once with the originaluserIDand 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:
@StateObjectforAuthService,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:
@EnvironmentObjectto read shared state;@Statefor local input (text fields, toggles, animation triggers). - Components (small, reusable views):
@Bindingfor parent state; no environment dependence — improves reusability and previewability. - Persistence: separate
Storelayer (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