Why Your @Observable Class init() Runs Multiple Times in SwiftUI


Greetings, traveler!

SwiftUI encourages a lightweight view layer. Views are value types, rebuilt frequently, and expected to be cheap. This mental model becomes tricky when you start storing reference types in @State, especially after adopting the Observation framework (@Observable).

A common surprise looks like this:

struct FeatureView: View {
    @State private var viewModel = FeatureViewModel()

    var body: some View {
        Text("Hello")
    }
}

You run the app, interact with the UI, and notice that FeatureViewModel.init() is executed multiple times. This can happen even when the UI behaves correctly and state appears to persist.

This article explains why it happens, why it becomes more visible with NavigationStack + NavigationPath, and how to structure your code so repeated initialization doesn’t turn into repeated work.

The Symptom

With Observation you typically define view models like this:

@Observable
final class FeatureViewModel {
    init() {
        print("FeatureViewModel init")
    }
}

You then store it in @State:

struct FeatureView: View {
    @State private var viewModel = FeatureViewModel()

    var body: some View {
        Text("Hello")
    }
}

You may expect that @State preserves its value and therefore init() should run once.

That expectation is understandable, but it is not how @State works.

The Root Cause: @State Always Evaluates Its Default Value

Apple’s documentation contains the key sentence that explains the behavior:

State property always instantiates its default value when SwiftUI instantiates the view. For this reason, avoid side effects and performance-intensive work when initializing the default value. For example, if a view updates frequently, allocating a new default object each time the view initializes can become expensive. Instead, you can defer the creation of the object using the task(priority:_:) modifier, which is called only once when the view first appears:

This is a precise statement, and it is easy to misread.

What actually happens

  • Your View is a struct.
  • SwiftUI is free to recreate that struct many times.
  • Every time SwiftUI recreates the view struct, Swift re-runs the initializer expression in @State private var viewModel = FeatureViewModel() to obtain the property’s default value.

The important detail:

  • The State storage may persist
  • The default value expression may still execute repeatedly

That is why init() can run multiple times even when the UI does not reset.

So, @State with @Observable triggers extra init() calls, while @StateObject does not.

Why @StateObject Looks “Better”

For old style MVVM you’d write:

@StateObject private var viewModel = FeatureViewModel()

and it generally initializes once.

There is an implementation difference:

  • @StateObject defers initialization using an @autoclosure
  • @State does not, it receives a concrete value immediately

So, @StateObject can avoid creating a new object when SwiftUI already has one. @State cannot.

This is not a bug in your code. It is a consequence of how the wrappers are designed.

@State Doesn’t Observe ObservableObject Changes

SwiftUI technically allows storing an ObservableObject instance inside @State. However, this does not behave like @StateObject: the view only updates when the reference itself changes (for example, when you assign a different object). Updates emitted by the object — such as changes to @Published properties — don’t trigger a re-render. If you need SwiftUI to track both the object’s identity and its internal published changes, Apple explicitly recommends using @StateObject instead.

Why It Gets More Noticeable With NavigationStack and NavigationPath

Many developers first notice the issue after moving to NavigationStack and using a NavigationPath:

  • You push a screen.
  • You go back.
  • You push again.
  • init() runs again.

In some cases, init() may even run multiple times during a single push, depending on how the destination is constructed.

This can feel wrong, but it is consistent with SwiftUI’s navigation model.

What’s happening

SwiftUI navigation is state-driven. When you use NavigationPath, SwiftUI rebuilds destination views based on the current path value.

That means that the “same screen” is often a new instance constructed from data in the path.

If your destination view creates its own view model using @State, this is a perfect recipe for repeated init().

This is also the reason why “it initializes even after returning to the screen” is especially common with NavigationStack-based flows.

A Workaround: Defer Creation With .task

We can consider deferring costly creation and avoiding side effects in the default value of @State.

A straightforward pattern is to use an optional:

struct ContentView: View {
    @State private var library: Library?


    var body: some View {
        LibraryView(library: library)
            .task {
                library = Library()
            }
    }
}

Apple suggests:

Delaying the creation of the observable state object ensures that unnecessary allocations of the object doesn’t happen each time SwiftUI initializes the view. Using the task(priority:_:) modifier is also an effective way to defer any other kind of work required to create the initial state of the view, such as network calls or file access.

A More “SwiftUI-Idiomatic” Variant: Keep VM Non-Optional, Move Work to .task

If you dislike optionals, you can keep the view model non-optional and make its initializer cheap.

Rule:

  • init() should allocate fields
  • .task should start async work and side effects

Example:

@Observable
final class FeatureViewModel {
    private var isStarted = false

    init() { }

    func start() async {
        guard !isStarted else { return }
        isStarted = true

        // network calls, timers, subscriptions, heavy setup...
    }
}
struct FeatureView: View {
    @State private var viewModel = FeatureViewModel()

    var body: some View {
        Text("Hello")
            .task { await viewModel.start() }
    }
}

Even if SwiftUI constructs the view multiple times and evaluates the default value again, the repeated init() becomes harmless because it does not do real work.

This is the simplest “native” solution that keeps your view code clean.

Also, .task is designed for this kind of work: SwiftUI schedules it before the view appears and can cancel it after the view disappears.

The Best Long-Term Fix: Own the ViewModel Higher in the Hierarchy

The most robust approach is architectural:

If a view model belongs to a feature, create it at the feature root and pass it down.

This removes the view model’s lifetime from “how SwiftUI instantiates this view” and moves it into a stable owner.

Example:

struct FeatureRoot: View {
    @State private var viewModel = FeatureViewModel()

    var body: some View {
        FeatureView(viewModel: viewModel)
    }
}

Then inside the child view:

struct FeatureView: View {
    @Bindable var viewModel: FeatureViewModel

    var body: some View {
        TextField("Title", text: $viewModel.title)
        		.task { await viewModel.start() }
    }
}

Alternatively, if you don’t require any bindings, simply proceed with that:

struct FeatureView: View {
    var viewModel: FeatureViewModel

    var body: some View {
        Text(viewModel.title)
        		.task { await viewModel.start() }
    }
}

This design scales better, especially when:

  • navigation can recreate screens
  • the same screen is reached from multiple entry points
  • you want predictable state retention inside a feature flow

It also reads well: the root owns, children consume.

What About DI Containers?

If you already use a DI container, it’s a reasonable place to assemble view models and their dependencies.

A good rule of thumb:

  • keep services in DI (API clients, repositories, storage, analytics)
  • create view models at composition roots (feature roots / coordinators)
  • avoid turning the container into a global view model registry

A container used as a view model cache can quickly become a hidden lifecycle manager. That tends to increase coupling and makes navigation-related behavior harder to reason about.

If you do want DI-based composition, prefer factories with explicit scoping:

  • application scope
  • session scope
  • feature scope

Don’t Fear Multiple init() Calls

In SwiftUI, repeated construction is normal.

The main mistake is placing work in the wrong place:

  • expensive initialization in @State default values
  • network calls in init()
  • subscriptions that should be owned by the screen lifecycle

Once you respect SwiftUI’s model, the issue becomes much smaller:

  • repeated init() is fine
  • repeated work is not

And What About deinit?

The same reasoning applies to deinit. When SwiftUI evaluates @State’s default value multiple times, the extra instances can be created and destroyed immediately, causing deinit to run more often than expected. For that reason, deinit shouldn’t be used for feature lifecycle logic (cleanup, analytics, persistence), because it may fire due to transient view construction rather than an actual user-driven dismissal.

Summary

  • @State can evaluate its default value multiple times because SwiftUI can instantiate a view struct multiple times.
  • Observation (@Observable) makes this more visible because @State does not defer object creation like @StateObject does.
  • With NavigationStack and NavigationPath, returning to a screen does not guarantee reuse, so initialization may happen again.
  • Apple recommends deferring object creation via .task (often using an optional state).
  • In practice, the cleanest options are:
    1. own view models higher in the feature hierarchy and pass them down as @Bindable or simply as a plain property (var) if you don’t need any bindings.
    2. keep init and deinit cheap and call heavy work from .task