Skip to content
intermediate Phase 2 · SwiftUI Fundamentals

State Management

Use @State, @Binding, @ObservedObject, @StateObject, @EnvironmentObject for reactive data flow.

55m
3 problems
Topic Progress 0%

@State & @Binding

@State for Local View State

@State is a property wrapper that stores state local to a view. When the value changes, SwiftUI automatically re-renders the view.

struct CounterView: View {
    @State private var count = 0
    
    var body: some View {
        VStack {
            Text("Count: \(count)")
                .font(.largeTitle)
            Button("Increment") {
                count += 1
            }
        }
    }
}

Key characteristics of @State:

  • Stored on the view's backing storage, not the struct itself
  • Persists across view re-renders (unlike regular properties)
  • Marked private because it is owned exclusively by the declaring view
  • Works only with value types (String, Int, Bool, structs, arrays)

Why @State Is Needed

Without @State, a property on a SwiftUI view struct would reset every time the view re-renders:

// BUG: This does not work
struct CounterView: View {
    var count = 0  // resets on every render!
    
    var body: some View {
        Button("Count: \(count)") {
            count += 1  // This actually creates a new struct, doesn't mutate
        }
    }
}

SwiftUI structs are value types and are recreated on every render. @State moves the storage to SwiftUI's managed storage, which persists across renders.

@Binding for Parent-Child Communication

@Binding creates a two-way connection to state owned by another view. The child reads and writes the parent's state.

// Parent owns the state
struct ParentView: View {
    @State private var isOn = false
    
    var body: some View {
        VStack {
            Text(isOn ? "ON" : "OFF")
            // Pass binding to child
            ToggleRow(isOn: $isOn)
        }
    }
}

// Child receives a binding
struct ToggleRow: View {
    @Binding var isOn: Bool
    
    var body: some View {
        Toggle("Enable Feature", isOn: $isOn)
    }
}

The $ prefix creates a Binding<Bool> from the @State property. The child can read and write this binding, and the parent's state updates accordingly.

Binding Anatomy

A Binding is a struct that wraps a getter and setter:

// What $isOn creates internally
Binding(
    get: { isOn },
    set: { isOn = $0 }
)

This two-way connection is why changes in the child immediately reflect in the parent and vice versa.

@Bindable (iOS 17+)

For observable objects, use @Bindable instead of @Binding:

@Observable
class FormViewModel {
    var name = ""
    var email = ""
}

struct FormView: View {
    @Bindable var viewModel: FormViewModel
    
    var body: some View {
        Form {
            TextField("Name", text: $viewModel.name)
            TextField("Email", text: $viewModel.email)
        }
    }
}

@Bindable creates bindings to properties on an observable object without requiring explicit @Published or @ObservedObject.

Observable Objects

The Need for Reference Type State

@State only works with value types. When you need state that:

  • Is shared across multiple views
  • Lives longer than a single view
  • Contains complex logic (network calls, data processing)

...you need a reference type (class) conforming to Observable.

@Observable (iOS 17+)

The modern approach uses the @Observable macro:

import Observation

@Observable
class UserStore {
    var users: [User] = []
    var isLoading = false
    var errorMessage: String?
    
    func fetchUsers() async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            users = try await apiService.fetchUsers()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

@Observable automatically tracks which properties are read during body evaluation and triggers updates only when those specific properties change.

Using @Observable in Views

struct UserListView: View {
    @State private var store = UserStore()
    
    var body: some View {
        List(store.users) { user in
            Text(user.name)
        }
        .overlay {
            if store.isLoading {
                ProgressView()
            }
        }
        .task { await store.fetchUsers() }
    }
}

Note: @State is used with @Observable classes because the store is a reference type. SwiftUI holds a reference to the same instance.

Legacy: @ObservedObject and @Published

Before iOS 17, you used ObservableObject with @Published:

class UserStore: ObservableObject {
    @Published var users: [User] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    func fetchUsers() async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            users = try await apiService.fetchUsers()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

struct UserListView: View {
    @StateObject private var store = UserStore()
    // or @ObservedObject if passed from parent
    
    var body: some View {
        List(store.users) { user in
            Text(user.name)
        }
    }
}

@StateObject vs @ObservedObject

Property Wrapper Ownership Use When
@StateObject Creates and owns the object The view creates the instance
@ObservedObject Observes but does not own The object is passed in from a parent
// @StateObject: view owns the store
struct ContentView: View {
    @StateObject private var store = UserStore()
}

// @ObservedObject: parent passes the store
struct UserListView: View {
    @ObservedObject var store: UserStore
}

Using @ObservedObject when you should use @StateObject causes the object to be recreated on every render, losing state.

Choosing Between @Observable and ObservableObject

  • New projects: Use @Observable (simpler, no @Published boilerplate)
  • Existing codebases: ObservableObject still works, migrate incrementally
  • iOS 16 support: Must use ObservableObject (Observation framework requires iOS 17)

@EnvironmentObject

The Problem of Deep Passing

When many views need the same state, passing it through every level of the view hierarchy becomes tedious:

// Without environment objects - tedious
struct ContentView: View {
    @StateObject var store = UserStore()
    
    var body: some View {
        NavigationView {
            UserListView(store: store)  // pass explicitly
        }
    }
}

struct UserListView: View {
    @ObservedObject var store: UserStore
    
    var body: some View {
        UserDetailView(store: store)  // pass again
    }
}

struct UserDetailView: View {
    @ObservedObject var store: UserStore
    // finally use it
}

Injecting with @EnvironmentObject

@EnvironmentObject makes an object available to all descendant views without explicit passing:

// Injection point (usually at App level)
@main
struct MyApp: App {
    @StateObject var store = UserStore()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(store)
        }
    }
}

// Any descendant can access it
struct UserDetailView: View {
    @EnvironmentObject var store: UserStore
    
    var body: some View {
        Text(store.selectedUser?.name ?? "No user")
    }
}

Modern Alternative: @Environment with @Observable

With @Observable, you can inject directly into the environment without the Object suffix:

// Injection
@main
struct MyApp: App {
    @State var store = UserStore()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(store)
        }
    }
}

// Access
struct UserDetailView: View {
    @Environment(UserStore.self) var store
    
    var body: some View {
        Text(store.selectedUser?.name ?? "No user")
    }
}

@Environment for System Values

@Environment also accesses system-provided values like color scheme, locale, and size class:

struct AdaptiveView: View {
    @Environment(\.colorScheme) var colorScheme
    @Environment(\.horizontalSizeClass) var sizeClass
    @Environment(\.locale) var locale
    
    var body: some View {
        VStack {
            Text("Color scheme: \(colorScheme == .dark ? "Dark" : "Light")")
            Text("Size class: \(sizeClass == .compact ? "Compact" : "Regular")")
            Text("Locale: \(locale.identifier)")
        }
    }
}

Environment Precedence

When the same environment value is set at multiple levels, the closest ancestor wins:

NavigationView {
    ChildView()
        .environment(MyStore())  // This value is used by ChildView
}
.environment(MyStore())  // This is overridden for ChildView's subtree

Best Practices

  • Use @EnvironmentObject / .environment() for truly global state (user auth, theme, settings)
  • Do not overuse it -- prefer explicit passing for state that is only needed by 1-2 levels
  • Always provide a default or handle the missing case, as environment objects are force-unwrapped if missing
  • Consider @Observable with .environment() as the modern default (iOS 17+)

Quiz

1. Why is @State necessary for view properties?

Question 1 options

2. What does the $ prefix do when accessing a @State property?

Question 2 options

3. When should you use @StateObject vs @ObservedObject?

Question 3 options

4. What is the modern alternative to @EnvironmentObject with @Published?

Question 4 options

5. What happens if you use @ObservedObject instead of @StateObject for a locally-created object?

Question 5 options

Flashcards

Question

What does @State do?

Answer

It stores view-local state in SwiftUI's managed storage, persisting across view re-renders. Works only with value types.

Question

What is the difference between @StateObject and @ObservedObject?

Answer

@StateObject creates and owns the object; @ObservedObject observes an externally-provided object without ownership.

Question

What does $value create?

Answer

A Binding that provides two-way read/write access to the underlying property value.

Question

How do you inject state for all descendant views?

Answer

Use .environmentObject(obj) or .environment(obj) at an ancestor, then @EnvironmentObject or @Environment(Type.self) in descendants.

Question

What replaced @Published in iOS 17+?

Answer

The @Observable macro, which automatically tracks property access and triggers granular updates.

Revision Notes

Key Takeaways

  • 1. @State manages local value-type state that persists across re-renders
  • 2. $value creates a Binding for two-way parent-child data flow
  • 3. @StateObject owns the object; @ObservedObject does not
  • 4. @Observable (iOS 17+) replaces @Published with automatic property tracking
  • 5. @EnvironmentObject eliminates tedious prop drilling for shared state

Interview Tips

  • Explain why @State is needed: structs are recreated, @State persists storage
  • Know the difference between @StateObject and @ObservedObject (ownership vs observation)
  • Be ready to describe the $ prefix creating Binding values
  • Understand the migration from ObservableObject/@Published to @Observable

Cheat Sheet

@State: local value-type state, persists across renders.
@Binding: two-way connection to parent's state, use $value to create.
@StateObject: view owns the reference type instance.
@ObservedObject: observes externally-provided reference type.
@Observable (iOS 17+): macro-based, no @Published needed.
@EnvironmentObject / .environment(): inject state for all descendants.
@Environment(SystemValue): access color scheme, locale, size class.