SwiftUI: views, state and navigation
Compose views, choose between State, Binding and Observable, build lists and forms, navigate with NavigationStack, and connect a view to async data.
Property wrappers and where state lives
import SwiftUI
struct CounterRow: View {
@Binding var count: Int // owned by the parent
var body: some View {
HStack {
Button("-") { count -= 1 }
Text("\(count)").monospacedDigit()
Button("+") { count += 1 }
}
}
}
@Observable
final class TaskStore {
var tasks: [Task] = []
var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
tasks = (try? await api.fetchTasks()) ?? []
}
}
struct TaskScreen: View {
@State private var store = TaskStore() // owns the instance
@State private var query = ""
@Environment(\.dismiss) private var dismiss
var filtered: [Task] {
query.isEmpty ? store.tasks : store.tasks.filter { $0.title.localizedCaseInsensitiveContains(query) }
}
var body: some View {
List(filtered) { task in
Text(task.title)
}
.searchable(text: $query)
.overlay { if store.isLoading { ProgressView() } }
.task { await store.load() } // cancelled when the view goes away
.toolbar { Button("Done") { dismiss() } }
}
}@Stateowns the value and survives re-renders; use it only in the view that creates the model.@Bindingis a two-way reference to state owned elsewhere.@Observabletracks which properties a view reads, so a change to an unrelated property does not re-render it..taskruns when the view appears and cancels automatically on disappear — prefer it to.onAppearplus a stored task.
NavigationStack and alerts
enum Route: Hashable {
case detail(id: Int)
case settings
}
struct RootView: View {
@State private var path: [Route] = []
@State private var showAlert = false
var body: some View {
NavigationStack(path: $path) {
List(1...20, id: \.self) { id in
NavigationLink("Task \(id)", value: Route.detail(id: id))
}
.navigationTitle("Tasks")
.navigationDestination(for: Route.self) { route in
switch route {
case .detail(let id): TaskDetail(id: id)
case .settings: SettingsView()
}
}
}
.alert("Session expired", isPresented: $showAlert) {
Button("Sign in") { signIn() }
Button("Cancel", role: .cancel) { }
} message: {
Text("Your session has ended. Please sign in again.")
}
.sheet(isPresented: .constant(true)) { EmptyView() }
}
}A typed path array is the whole navigation state. Because it is a value, you can save it, restore it, or test a deep link by assigning the path rather than simulating taps.
Connecting a view to async data
struct ArticleDetail: View {
let id: Int
@State private var article: Article?
@State private var error: String?
var body: some View {
Group {
if let article {
ScrollView {
Text(article.title).font(.title)
Text(article.body)
}
} else if let error {
ContentUnavailableView("Could not load", systemImage: "wifi.exclamationmark", description: Text(error))
} else {
ProgressView()
}
}
.task(id: id) { // re-runs when id changes, cancels the old one
do {
article = try await API.shared.article(id)
} catch is CancellationError {
} catch {
self.error = error.localizedDescription
}
}
}
}💡
.task(id:) is the right tool for a screen driven by a parameter. When the id changes, SwiftUI cancels the previous task before starting the new one, which removes the classic race where an old response overwrites a newer one.FAQ
When should I use <code>@StateObject</code> or <code>ObservableObject</code>?
Only for code you cannot change to
@Observable, or a third-party type. For new code, @Observable with @State is simpler and more efficient.Why does my view not update when the model changes?
The model is a class not marked
@Observable, it is stored in a plain let instead of @State, or the mutation happens off the main actor. Check all three.Related
Networking, persistence and Codable Testing with XCTest and Swift Testing
Last refreshed 2026-09-18.