What is the lifecycle of a SwiftUI view? When do onAppear and task run?
TL;DR
A SwiftUI View is a cheap struct. SwiftUI creates it many times. Its init and body can run often.
The real lifetime is the view's identity. @State and .task live as long as that identity.
onAppear runs when the view appears. .task runs async work and cancels it automatically when the view goes away.
Three different things
| Event | How often | Example |
|---|---|---|
init of the struct | Every time the parent's body runs | Many times |
body | When the data it reads changes | Many times |
| Identity created / destroyed | Once per appearance in the tree | @State is created and freed |
So do not put side effects in init or body.
struct ProfileView: View {
init() {
print("init") // prints often, not a good place to load data
}
var body: some View {
Text("Profile")
}
}onAppear and onDisappear
Text("Hello")
.onAppear { analytics.track("hello_shown") }
.onDisappear { analytics.track("hello_hidden") }- Runs before the first frame is shown.
- Can run more than once. For example, after you go back from a pushed screen, or when a row in a lazy list scrolls back in.
.task: async work tied to the view
struct UserScreen: View {
let userID: User.ID
@State private var user: User?
var body: some View {
Group {
if let user { UserCard(user: user) } else { ProgressView() }
}
.task {
user = try? await api.user(id: userID)
}
}
}When the view disappears, SwiftUI cancels the task. No leaks and no work after the screen is gone.
The task runs on the main actor, because View is @MainActor.
.task(id:): restart when a value changes
.task(id: searchText) {
try? await Task.sleep(for: .milliseconds(300)) // simple debounce
results = await search(searchText)
}When searchText changes, the old task is cancelled and a new one starts.
This is a clean way to debounce search.
onChange(of:)
.onChange(of: selectedTab) { oldValue, newValue in
print("Tab moved from \(oldValue) to \(newValue)")
}The two-parameter form is the iOS 17 version. The one-parameter version is deprecated.
Add initial: true to also run it when the view first appears.
Scene phase
For app-level events, read scenePhase:
@Environment(\.scenePhase) private var scenePhase
.onChange(of: scenePhase) { _, phase in
if phase == .background { store.save() }
}Compared with UIKit
| UIKit | SwiftUI |
|---|---|
viewDidLoad | No direct match. Use .task or the model's init. |
viewWillAppear / viewDidAppear | onAppear |
viewDidDisappear | onDisappear |
Cancel work in deinit | .task cancels for you |
Common mistakes
- Loading data in
init. It runs often, even for screens that are never shown. - Expecting
onAppearto run only once. Use a flag or load in the model. - Starting a
Task { }insideonAppear. It is not cancelled on disappear. Use.taskinstead. - Expecting a
@Stateobject'sinitto run once. The expression may run on every struct init, but SwiftUI keeps the first value.
Common follow-up questions
- When does
.taskrun compared toonAppear? At about the same time, before the view appears. Both are fine for kicking off work. - Does
onDisappearrun when the app goes to the background? No. UsescenePhasefor that. - How do you run something only once? Load in an
@Observablemodel and guard with a flag, or use.taskon a view that does not reappear. - What happens to
.taskin aListrow? It starts when the row appears and is cancelled when the row scrolls away.