SwiftUI Async Work: .onAppear() vs .task {}
SwiftUI Async Work: .onAppear() vs .task {}
If you've been wrapping async calls in .onAppear(), you're not alone. I did this for months before discovering why my API calls were firing twice and my app was crashing with threading issues.
Here's what I wish someone had told me on day one.
The Problem with .onAppear()
SwiftUI views aren't like UIKit view controllers. They're lightweight structs that get recreated constantly. When SwiftUI rebuilds your view hierarchy—and it does this more often than you think—.onAppear() fires again.
This causes real problems:
struct ProfileView: View {
@State private var user: User?
var body: some View {
VStack {
if let user {
Text(user.name)
}
}
.onAppear {
Task {
user = await fetchUser()
}
}
}
}
What happens here?
- View appears, starts fetching user
- User navigates away
- Task keeps running in the background
- User navigates back
- View recreates, starts a second fetch
- First task completes, updates
@Stateon a deallocated view - App crashes or shows stale data
I've debugged this exact scenario more times than I'd like to admit.
Why .task {} Is Different
The .task {} modifier was built specifically for async work. It understands SwiftUI's lifecycle:
struct ProfileView: View {
@State private var user: User?
var body: some View {
VStack {
if let user {
Text(user.name)
}
}
.task {
user = await fetchUser()
}
}
}
When you use .task {}:
- SwiftUI automatically cancels the task when the view disappears
- No duplicate calls when the view rebuilds with the same identity
- Task respects cooperative cancellation
- Memory leaks are avoided
The Identity Trick
Here's where .task {} gets really powerful. You can restart the task only when specific data changes:
struct UserProfileView: View {
let userID: String
@State private var profile: Profile?
var body: some View {
VStack {
if let profile {
ProfileContent(profile: profile)
}
}
.task(id: userID) {
profile = await fetchProfile(for: userID)
}
}
}
The task only runs when:
- The view first appears
userIDchanges
If the view rebuilds for any other reason—parent view updates, orientation changes, etc.—the task doesn't fire again. This is huge for performance.
When to Use Each
I follow a simple rule: if your code has await, use .task {}.
Use .onAppear() for:
- Analytics tracking
- Simple animations
- Setting focus
- Updating non-async state
.onAppear {
Analytics.track("ScreenViewed")
focusedField = .username
}
Use .task {} for:
- API calls
- Database queries
- File I/O
- Any async operation
.task {
messages = await database.fetchMessages()
}
Real-World Example
Here's a common pattern I see in production code:
struct FeedView: View {
@State private var posts: [Post] = []
@State private var isLoading = false
var body: some View {
List(posts) { post in
PostRow(post: post)
}
.task {
isLoading = true
do {
posts = try await api.fetchPosts()
} catch {
print("Failed to load posts: \(error)")
}
isLoading = false
}
.overlay {
if isLoading {
ProgressView()
}
}
}
}
Clean, predictable, and safe. The task cancels automatically if the user navigates away, and you won't get duplicate API calls.
Supporting iOS 14
If you're still supporting iOS 14 (.task {} requires iOS 15+), you need to manually manage cancellation:
struct ProfileView: View {
@State private var user: User?
@State private var loadTask: Task<Void, Never>?
var body: some View {
VStack {
if let user {
Text(user.name)
}
}
.onAppear {
loadTask = Task {
user = await fetchUser()
}
}
.onDisappear {
loadTask?.cancel()
}
}
}
It's more verbose, but it gives you the same safety guarantees.
The Bottom Line
SwiftUI views are ephemeral. They live, die, and rebuild constantly. .task {} was designed for this reality—it ties async work to view identity and handles cancellation automatically.
Next time you reach for .onAppear() with a Task {} inside, stop. Use .task {} instead. Your future self will thank you when you're not debugging phantom API calls at midnight.