State and navigation

Where each kind of state belongs, how the property wrappers differ, and how to move between screens with NavigationStack.

Choosing the right state container

SwiftUI is a function of state: change the state, and the framework recomputes the affected views. Picking the wrong owner for a piece of state is the most common source of bugs.

WrapperOwns the value?Use for
@StateYesLocal, value-type state owned by one view, such as a toggle or a text field
@BindingNoPassing read/write access to state owned by a parent
@StateObjectYesCreating and owning a reference-type model that must survive redraws
@ObservedObjectNoA model created elsewhere and passed in
@EnvironmentNoValues injected from an ancestor, such as colour scheme or a model container
@Observable classThe modern observation macro; tracks only the properties a view reads
import Observation

@Observable
final class Basket {
    var items: [String] = []
    var total: Int { items.count }

    func add(_ item: String) { items.append(item) }
}

struct BasketView: View {
    @State private var basket = Basket()
    @State private var draft = ""

    var body: some View {
        VStack {
            TextField("Item", text: $draft)
            Button("Add") {
                basket.add(draft.trimmingCharacters(in: .whitespaces))
                draft = ""
            }
            Text("In basket: " + String(basket.total))
        }
        .padding()
    }
}
⚠️
Creating a reference-type model with @State or @StateObject from a view that is itself recreated by a parent can silently reset it. If a model must outlive the screen, inject it from an ancestor with @Environment.

Modern SwiftUI navigation is driven by a path value rather than by nested links. NavigationStack holds an array of destinations; pushing and popping means appending to and removing from that array, which makes deep links straightforward.

struct RootView: View {
    @State private var path: [Task] = []

    var body: some View {
        NavigationStack(path: $path) {
            List(tasks) { task in
                NavigationLink(value: task) {
                    TaskRow(task: task)
                }
            }
            .navigationTitle("Tasks")
            .navigationDestination(for: Task.self) { task in
                TaskDetail(task: task)
            }
        }
    }
}

// later, jump straight to a screen
path = [someTask]
  • NavigationStack for a linear drill-down, NavigationSplitView for a sidebar layout on iPad and Mac.
  • .sheet and .fullScreenCover present modal content that is dismissed with the environment's dismiss action.
  • TabView splits independent areas of the app; give each tab its own stack so switching tabs keeps its navigated position.

FAQ

When do I need a ViewModel at all?
When logic outlives one view, needs testing in isolation, or coordinates several screens. For a single toggle or text field, local @State is simpler and correct.
Why did my screen pop back on its own?
The navigation path was stored in state that got recreated, typically because the view owning it was replaced. Hoist the path to the highest view that should keep it.

Xcode projects and SwiftUI views Persistence and the App Store

Last refreshed 2026-09-18.