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.
| Wrapper | Owns the value? | Use for |
|---|---|---|
@State | Yes | Local, value-type state owned by one view, such as a toggle or a text field |
@Binding | No | Passing read/write access to state owned by a parent |
@StateObject | Yes | Creating and owning a reference-type model that must survive redraws |
@ObservedObject | No | A model created elsewhere and passed in |
@Environment | No | Values injected from an ancestor, such as colour scheme or a model container |
@Observable class | — | The 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.Moving between screens
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]NavigationStackfor a linear drill-down,NavigationSplitViewfor a sidebar layout on iPad and Mac..sheetand.fullScreenCoverpresent modal content that is dismissed with the environment's dismiss action.TabViewsplits 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.
Related
Xcode projects and SwiftUI views Persistence and the App Store
Last refreshed 2026-09-18.