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() } }
    }
}
  • @State owns the value and survives re-renders; use it only in the view that creates the model.
  • @Binding is a two-way reference to state owned elsewhere.
  • @Observable tracks which properties a view reads, so a change to an unrelated property does not re-render it.
  • .task runs when the view appears and cancels automatically on disappear — prefer it to .onAppear plus a stored task.
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.

Networking, persistence and Codable Testing with XCTest and Swift Testing

Last refreshed 2026-09-18.