```html SwiftUI: How to async await (iOS 17+, 2026)

How to Use async/await in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: async/await Updated: May 11, 2026
TL;DR

Attach a .task modifier to your view and call await inside it — SwiftUI cancels the task automatically when the view disappears. Store results in @State to trigger re-renders.

struct QuoteView: View {
    @State private var quote = "Loading…"

    var body: some View {
        Text(quote)
            .task {
                quote = await fetchQuote()
            }
    }
}

func fetchQuote() async -> String {
    try? await Task.sleep(for: .seconds(1))
    return "Simplicity is the ultimate sophistication."
}

Full implementation

The example below models a real-world pattern: an @Observable view model owns the async work, while the view drives it through the .task modifier and a manual refresh button. A dedicated LoadingState enum keeps the UI and network state tightly coupled — no stale boolean flags.

import SwiftUI

// MARK: - Model

struct Post: Identifiable, Decodable {
    let id: Int
    let title: String
    let body: String
}

// MARK: - Loading State

enum LoadingState<T> {
    case idle
    case loading
    case success(T)
    case failure(String)
}

// MARK: - View Model

@Observable
final class PostsViewModel {
    var state: LoadingState<[Post]> = .idle

    func loadPosts() async {
        state = .loading
        do {
            let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
            let (data, _) = try await URLSession.shared.data(from: url)
            let posts = try JSONDecoder().decode([Post].self, from: data)
            state = .success(posts)
        } catch {
            state = .failure(error.localizedDescription)
        }
    }
}

// MARK: - View

struct PostsView: View {
    @State private var viewModel = PostsViewModel()

    var body: some View {
        NavigationStack {
            content
                .navigationTitle("Posts")
                .toolbar {
                    ToolbarItem(placement: .primaryAction) {
                        Button("Refresh") {
                            Task { await viewModel.loadPosts() }
                        }
                        .disabled(isLoading)
                    }
                }
        }
        .task {
            await viewModel.loadPosts()
        }
    }

    @ViewBuilder
    private var content: some View {
        switch viewModel.state {
        case .idle:
            ContentUnavailableView("No posts yet", systemImage: "tray")

        case .loading:
            ProgressView("Fetching posts…")
                .frame(maxWidth: .infinity, maxHeight: .infinity)

        case .success(let posts):
            List(posts) { post in
                VStack(alignment: .leading, spacing: 4) {
                    Text(post.title)
                        .font(.headline)
                        .accessibilityLabel("Title: \(post.title)")
                    Text(post.body)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                        .lineLimit(2)
                }
                .padding(.vertical, 4)
            }

        case .failure(let message):
            ContentUnavailableView(
                "Something went wrong",
                systemImage: "exclamationmark.triangle",
                description: Text(message)
            )
        }
    }

    private var isLoading: Bool {
        if case .loading = viewModel.state { return true }
        return false
    }
}

#Preview {
    PostsView()
}

How it works

  1. .task modifier (line 46). SwiftUI creates a structured Task bound to the view's lifetime. When the view disappears, the task is cancelled — no manual onDisappear cleanup needed.
  2. @Observable view model (line 13). The @Observable macro (iOS 17+) replaces ObservableObject and @Published. Any mutation of state automatically invalidates the dependent views.
  3. URLSession async/await (lines 22–23). URLSession.shared.data(from:) is the native async API — no completion handlers or Combine pipelines required. It suspends the task while the network call is in flight, freeing the thread for other work.
  4. Manual refresh with Task { } (line 52). Sync button actions can't call await directly. Wrapping in Task { } creates a detached async context while keeping the call on the main actor so state mutations are safe.
  5. LoadingState enum (lines 9–14). Modelling network state as an enum with associated values eliminates impossible states like isLoading = true and error != nil existing simultaneously. The @ViewBuilder switch exhausts all cases at compile time.

Variants

Streaming with AsyncSequence

When your data source emits values over time — a WebSocket feed, file line reader, or timer — use for await to consume each element as it arrives.

struct TimerView: View {
    @State private var ticks = 0

    var body: some View {
        Text("Ticks: \(ticks)")
            .font(.largeTitle)
            .task {
                // AsyncStream bridges callback-based APIs into AsyncSequence
                let stream = AsyncStream<Int> { continuation in
                    var count = 0
                    let timer = Timer.scheduledTimer(
                        withTimeInterval: 1.0,
                        repeats: true
                    ) { _ in
                        count += 1
                        continuation.yield(count)
                    }
                    continuation.onTermination = { _ in timer.invalidate() }
                }

                for await tick in stream {
                    ticks = tick
                    if tick >= 10 { break }
                }
            }
    }
}

Parallel async calls with async let

When two independent network calls are needed before rendering, fire them concurrently with async let. Both requests start simultaneously; await collects them only after both complete — cutting total wait time roughly in half compared to sequential awaits.

func loadDashboard() async throws -> (User, [Post]) {
    async let user = fetchUser(id: 1)
    async let posts = fetchPosts(userId: 1)
    // Both requests run in parallel; await collects both results
    return try await (user, posts)
}

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement async/await in SwiftUI for iOS 17+.
Use async/await with URLSession, .task modifier, and @Observable.
Make it accessible (VoiceOver labels on list rows and loading states).
Add a #Preview with realistic sample data.

In the Soarias Build phase, dropping this prompt into an active session wires up your data layer immediately — letting you stay in flow and move straight from scaffolded screens to live network data.

Related

FAQ

Does this work on iOS 16?

The async/await syntax and .task modifier are available from iOS 15. However, the @Observable macro used in the full example requires iOS 17+. On iOS 16 replace @Observable final class with @MainActor final class … : ObservableObject and mark published properties with @Published. The .task modifier and all async/await URLSession APIs remain identical.

When should I use Task.detached vs Task { }?

Prefer Task { } in views — it inherits the caller's actor context (usually @MainActor), so state writes are safe. Use Task.detached { } only when you explicitly want to escape the current actor — for example, CPU-bound work you want off the main thread. In that case, marshal results back with await MainActor.run { } before touching any UI state.

What is the UIKit equivalent?

In UIKit you'd typically kick off work in viewDidLoad or viewWillAppear with Task { await loadData() }, then cancel in viewDidDisappear by storing the task reference and calling task?.cancel(). The SwiftUI .task modifier handles this entire lifecycle automatically, which is why it's preferred in SwiftUI.

Last reviewed: 2026-05-11 by the Soarias team.

```