iosNudge

What is the lifecycle of a SwiftUI view? When do onAppear and task run?

MediumAsked oftenSwiftUIConcurrency 2 min read

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

EventHow oftenExample
init of the structEvery time the parent's body runsMany times
bodyWhen the data it reads changesMany times
Identity created / destroyedOnce 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

UIKitSwiftUI
viewDidLoadNo direct match. Use .task or the model's init.
viewWillAppear / viewDidAppearonAppear
viewDidDisappearonDisappear
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 onAppear to run only once. Use a flag or load in the model.
  • Starting a Task { } inside onAppear. It is not cancelled on disappear. Use .task instead.
  • Expecting a @State object's init to run once. The expression may run on every struct init, but SwiftUI keeps the first value.

Common follow-up questions

  • When does .task run compared to onAppear? At about the same time, before the view appears. Both are fine for kicking off work.
  • Does onDisappear run when the app goes to the background? No. Use scenePhase for that.
  • How do you run something only once? Load in an @Observable model and guard with a flag, or use .task on a view that does not reappear.
  • What happens to .task in a List row? It starts when the row appears and is cancelled when the row scrolls away.