How to Use async/await in SwiftUI
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
-
.task modifier (line 46). SwiftUI creates a structured
Taskbound to the view's lifetime. When the view disappears, the task is cancelled — no manualonDisappearcleanup needed. -
@Observable view model (line 13). The
@Observablemacro (iOS 17+) replacesObservableObjectand@Published. Any mutation ofstateautomatically invalidates the dependent views. -
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. -
Manual refresh with Task { } (line 52). Sync button actions can't call
awaitdirectly. Wrapping inTask { }creates a detached async context while keeping the call on the main actor so state mutations are safe. -
LoadingState enum (lines 9–14). Modelling network state as an enum with associated
values eliminates impossible states like
isLoading = trueanderror != nilexisting simultaneously. The@ViewBuilderswitch 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
-
⚠️ iOS 17 @Observable requirement. The
@Observablemacro is iOS 17+ only. If you must support iOS 16, fall back toObservableObject+@Published, but note that@State private var viewModelwon't trigger view updates on iOS 16 — you'll need@StateObject. -
⚠️ Main actor isolation for UI updates. Mutations to
@Stateor@Observableproperties must happen on the main actor. If you callawait someBackgroundActor.work()and update UI directly after, Swift will surface a concurrency warning — annotate the view model@MainActoror wrap mutations inawait MainActor.run { }. -
⚠️ Task leaks from Button actions. A bare
Task { }inside a button is unstructured — it isn't cancelled when the view disappears. For long-running work triggered by buttons, store the task in a@State private var currentTask: Task<Void, Never>?and callcurrentTask?.cancel()in.onDisappear.
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.