Understanding the SwiftUI App Lifecycle: `@main`, `Scene`, and `WindowGroup`


Every iOS developer knows viewDidLoad and applicationDidBecomeActive — but those belong to UIKit. When you write a SwiftUI app, the lifecycle model is entirely different, and if you try to port UIKit habits into it, you’ll end up with code that fires unexpectedly, misses background transitions, or simply doesn’t compile in Swift 6.

This post covers the complete SwiftUI lifecycle from the @main entry point through scene phases, state restoration, and background tasks. It assumes you’re already comfortable with SwiftUI state management. Multi-platform details (macOS, iPad multi-window) are touched on but not exhaustively covered — those deserve their own posts.

Contents

The Problem

Imagine you’re building Pixar Film Tracker — an app that fetches the latest Pixar releases and caches them locally. When the app becomes active, you want to refresh the list. When it goes to the background, you want to save unsaved state and cancel non-critical network work.

A developer coming from UIKit might reach for .onAppear on the root view:

struct ContentView: View {
    @StateObject private var viewModel = FilmLibraryViewModel()

    var body: some View {
        FilmListView(films: viewModel.films)
            .onAppear {
                // ❌ This fires every time the view appears,
                // not just when the app becomes active.
                Task { await viewModel.refreshFilms() }
            }
    }
}

This approach has three problems. First, .onAppear fires whenever the view is pushed onto the navigation stack or presented as a sheet — not just on app launch or foreground transition. Second, it gives you no way to detect the background transition, so you can’t cancel tasks or save state. Third, if you have multiple scenes open on iPad, each scene’s root view fires .onAppear independently, potentially triggering duplicate refreshes.

The correct tool for lifecycle observation in SwiftUI is scenePhase, and the correct entry point for app-level setup is the App protocol.

The @main Entry Point and the App Protocol

In a UIKit app, execution begins at UIApplicationMain and flows through AppDelegate. In a SwiftUI app, @main marks a type conforming to the App protocol as the entry point.

import SwiftUI

@main
struct PixarFilmTrackerApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

The App protocol requires a single body property returning some Scene. This is the scene graph — the top-level description of every window or window group your app can display. The system reads this description and instantiates the appropriate windows based on context (iPhone, iPad, macOS, visionOS).

@main is a Swift attribute (introduced in SE-0281) that designates the program’s entry point. Only one type in your module can carry @main.

Note: There is no AppDelegate by default. If you need one — for push notification registration, third-party SDK setup, or other UIApplicationDelegate callbacks — you add it explicitly with UIApplicationDelegateAdaptor. See Bridging to UIKit with UIApplicationDelegateAdaptor below.

You can inject environment objects and app-wide dependencies directly from App:

@main
struct PixarFilmTrackerApp: App {
    // App-scoped state, shared across all scenes
    @StateObject private var filmLibrary = FilmLibrary()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(filmLibrary)
        }
    }
}

@StateObject in the App struct lives for the entire lifetime of the application process, making it the right place for app-level singletons.

Scene Types

The body of an App is composed of Scene values. SwiftUI ships four built-in scene types:

WindowGroup

WindowGroup is the standard container for most apps. On iPhone it manages a single window; on iPad and macOS it supports multiple simultaneous windows, each running an independent instance of the root view hierarchy.

WindowGroup {
    FilmLibraryView()
}

You can give a WindowGroup an identifier when you need to open specific windows programmatically:

WindowGroup("Film Detail", id: "film-detail", for: Film.ID.self) { $filmID in
    FilmDetailView(filmID: filmID)
}

DocumentGroup

DocumentGroup is for document-based apps — apps that create, open, and save files. It wires up the system’s document browser UI and manages file coordination automatically.

DocumentGroup(newDocument: StoryboardDocument()) { file in
    StoryboardEditorView(document: file.$document)
}

Settings (macOS only)

Settings renders the macOS Preferences/Settings window. Xcode and other macOS apps use this to provide a dedicated settings pane accessible from the application menu.

#if os(macOS)
Settings {
    PixarFilmTrackerSettings()
}
#endif

MenuBarExtra adds an item to the macOS menu bar. Useful for utility apps that live in the status area.

#if os(macOS)
MenuBarExtra("Pixar Tracker", systemImage: "film") {
    MenuBarQuickView()
}
#endif

Observing Lifecycle Events with scenePhase

The scenePhase environment value exposes the current phase of a scene. It has three cases:

PhaseMeaning
.activeThe scene is visible and interactive
.inactiveThe scene is visible but not interactive (e.g., app switcher, incoming call)
.backgroundThe scene is not visible and will soon be suspended

You observe scenePhase with the .onChange(of:) modifier. Where you attach it matters:

  • On the App struct: receives the aggregate phase of all scenes. The app is .active if any scene is active; it’s .background only when all scenes are backgrounded.
  • On a WindowGroup: receives the aggregate phase for that window group.
  • On a view: receives the phase for the nearest enclosing scene.

For app-level lifecycle work, attach the observer directly in the App struct:

@main
struct PixarFilmTrackerApp: App {
    @Environment(\.scenePhase) private var scenePhase
    @StateObject private var filmLibrary = FilmLibrary()

    // Keep a reference to cancel on background
    @State private var refreshTask: Task<Void, Never>?

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(filmLibrary)
        }
        .onChange(of: scenePhase) { _, newPhase in
            switch newPhase {
            case .active:
                // App became visible and interactive — resume or start work
                refreshTask = Task {
                    await filmLibrary.refreshIfStale()
                }
            case .background:
                // App is backgrounded — cancel non-essential tasks, save state
                refreshTask?.cancel()
                filmLibrary.saveUnsavedChanges()
            case .inactive:
                // App is transitioning (app switcher, incoming call) — usually no action needed
                break
            @unknown default:
                break
            }
        }
    }
}

The @unknown default case is mandatory for exhaustive switches on non-frozen enums like ScenePhase. The compiler will warn you if you omit it — and it protects against future phases Apple might add.

Warning: Avoid starting heavy work in .inactive. This phase is transient and fires frequently during interactions that don’t result in a background transition (e.g., the user pulls down Notification Center). Reserve actual work for .active and cleanup for .background.

Bridging to UIKit with UIApplicationDelegateAdaptor

Some tasks still require UIApplicationDelegate — push notification token registration being the most common. For those cases, add a delegate and register it with UIApplicationDelegateAdaptor:

final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // Third-party SDK setup, Crashlytics, etc.
        FirebaseApp.configure()
        return true
    }

    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        // Send token to your push notification server
        PushRegistrationService.shared.sendToken(deviceToken)
    }

    func application(
        _ application: UIApplication,
        didFailToRegisterForRemoteNotificationsWithError error: Error
    ) {
        print("APNs registration failed: \(error)")
    }
}

@main
struct PixarFilmTrackerApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Note: When you provide a UIApplicationDelegateAdaptor, SwiftUI still owns the app lifecycle — your delegate is an observer, not the primary controller. Do not call UIApplicationMain yourself or return a custom NSPrincipalClass.

If you also need UIWindowSceneDelegate — for fine-grained scene-level callbacks — you can return a custom scene delegate class from application(_:configurationForConnecting:options:) in your AppDelegate. However, this is rarely necessary in pure SwiftUI apps.

Per-Scene State Restoration with @SceneStorage

@SceneStorage persists lightweight UI state (selected tab, scroll position, active film ID) on a per-scene basis. Unlike @AppStorage which is global, @SceneStorage is scoped to its enclosing scene — making it the right tool for iPad apps with multiple windows open simultaneously.

struct FilmLibraryView: View {
    // Automatically saved and restored per scene
    @SceneStorage("selectedFilmID") private var selectedFilmID: String?
    @SceneStorage("selectedTab") private var selectedTab: String = "library"

    var body: some View {
        TabView(selection: $selectedTab) {
            FilmListView(selectedFilmID: $selectedFilmID)
                .tabItem { Label("Library", systemImage: "film") }
                .tag("library")

            SearchView()
                .tabItem { Label("Search", systemImage: "magnifyingglass") }
                .tag("search")
        }
    }
}

The system writes @SceneStorage values to disk automatically before the scene enters the background and restores them when the scene reconnects. The storage key must be unique within the app — collisions will cause one scene to overwrite another’s state.

Warning: @SceneStorage supports only a limited set of types: Bool, Int, Double, String, URL, Data, and RawRepresentable types whose raw values are one of the above. For complex model objects, serialize to Data or persist via SwiftData/UserDefaults and store only a reference (like an ID) in @SceneStorage.

Background Tasks

For work that must complete after the app enters the background, use the BackgroundTask API introduced in iOS 16 alongside BGTaskScheduler.

Apple Docs: BackgroundTask — SwiftUI | BGTaskScheduler — BackgroundTasks

Register and schedule background app refresh from within the App struct:

@main
struct PixarFilmTrackerApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        // iOS 16+ declarative background task handler
        .backgroundTask(.appRefresh("com.pixartracker.refresh")) {
            // This closure runs in the background.
            // You have a limited budget (~30 seconds).
            let library = FilmLibrary()
            await library.refreshFromNetwork()

            // Schedule the next refresh
            scheduleAppRefresh()
        }
    }

    private func scheduleAppRefresh() {
        let request = BGAppRefreshTaskRequest(identifier: "com.pixartracker.refresh")
        request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 minutes
        try? BGTaskScheduler.shared.submit(request)
    }
}

You must declare the task identifier in Info.plist under BGTaskSchedulerPermittedIdentifiers and enable the Background Modes capability with Background fetch checked.

Note: The system decides when background tasks actually run — your earliestBeginDate is a floor, not a guarantee. In development, use e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.pixartracker.refresh"] in the Xcode debugger to trigger the task immediately.

Multi-Window Support on iPad and macOS

On iPad (iPadOS 16+) and macOS, WindowGroup can spawn multiple simultaneous window instances. Use the openWindow environment action to open a specific window from your code:

struct FilmDetailView: View {
    let film: Film
    @Environment(\.openWindow) private var openWindow
    @Environment(\.supportsMultipleWindows) private var supportsMultipleWindows

    var body: some View {
        VStack {
            // ...
            if supportsMultipleWindows {
                Button("Open in New Window") {
                    openWindow(value: film.id)
                }
            }
        }
    }
}

The supportsMultipleWindows environment value is false on iPhone and true on iPad and macOS — use it to conditionally show multi-window affordances rather than hardcoding platform checks.

Advanced Usage

Handoff and Spotlight Integration

To support Handoff (continuing an activity on another device), call NSUserActivity from your views and handle it in onContinueUserActivity:

struct FilmDetailView: View {
    let film: Film

    var body: some View {
        FilmContentView(film: film)
            .userActivity("com.pixartracker.viewFilm") { activity in
                // Advertise current activity for Handoff
                activity.title = film.title
                activity.userInfo = ["filmID": film.id.uuidString]
                activity.isEligibleForHandoff = true
                activity.isEligibleForSearch = true  // Spotlight
            }
    }
}

@main
struct PixarFilmTrackerApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onContinueUserActivity("com.pixartracker.viewFilm") { activity in
                    guard let filmIDString = activity.userInfo?["filmID"] as? String,
                          let filmID = UUID(uuidString: filmIDString) else { return }
                    NavigationModel.shared.navigateToFilm(id: filmID)
                }
        }
    }
}

UIWindowSceneDelegate for Fine-Grained Scene Events

For per-scene events not exposed through scenePhase (such as windowScene(_:userDidAcceptCloudKitShareWith:)), you can provide a custom UIWindowSceneDelegate:

final class SceneDelegate: NSObject, UIWindowSceneDelegate {
    func windowScene(
        _ windowScene: UIWindowScene,
        userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShareMetadata
    ) {
        // Handle CloudKit share acceptance
    }
}

final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        configurationForConnecting connectingSceneSession: UISceneSession,
        options: UIScene.ConnectionOptions
    ) -> UISceneConfiguration {
        let config = UISceneConfiguration(
            name: "Default Configuration",
            sessionRole: connectingSceneSession.role
        )
        config.delegateClass = SceneDelegate.self
        return config
    }
}

This is an escape hatch — the vast majority of SwiftUI lifecycle work can be done without a scene delegate.

When to Use (and When Not To)

ScenarioRecommendation
Run code on app launch (one-time setup)Use the App struct’s init() or a .task modifier on the root view’s outer container
React to foreground/background transitionsUse .onChange(of: scenePhase) on the App struct
Restore UI state per scene (selected tab, row)Use @SceneStorage
Global app state (user session, feature flags)Use @StateObject in the App struct, injected via .environmentObject
APNs registration, third-party SDK setupUse UIApplicationDelegateAdaptor
Work that must run in the backgroundUse the backgroundTask scene modifier + BGTaskScheduler
.onAppear for lifecycleAvoid for app-level lifecycle — use it only for view-level side effects like starting animations
AppDelegate-first architecture in new SwiftUI appsAvoid — the App protocol is the intended entry point; use UIApplicationDelegateAdaptor only for specific callbacks

Summary

  • The @main attribute marks your App-conforming struct as the program entry point — there is no AppDelegate by default.
  • WindowGroup is the standard scene for most apps; DocumentGroup, Settings, and MenuBarExtra serve specialized roles on macOS.
  • scenePhase is the correct way to observe foreground/background transitions in SwiftUI — not .onAppear.
  • Attach scenePhase observers on the App struct (not individual views) to track aggregate app state.
  • Use @SceneStorage for lightweight per-scene UI state that should survive backgrounding and multi-window usage.
  • For APNs, Crashlytics, or other UIApplicationDelegate callbacks, add UIApplicationDelegateAdaptor — it doesn’t cede lifecycle control to UIKit.
  • iOS 16+ backgroundTask scene modifiers give you a clean, declarative way to schedule and run background app refresh tasks.

Push notifications are the natural next step — they require UIApplicationDelegateAdaptor for token registration and deep UNUserNotificationCenterDelegate integration. Head over to Push Notifications in Swift for the full implementation.