Post

SwiftUI Gotcha - @State wrappedValue Inside init()

SwiftUI Gotcha - @State wrappedValue Inside init()

You’ve probably heard the rule: SwiftUI only uses @State’s initial value once. After that, it ignores whatever you pass in subsequent init() calls. But what exactly happens to _state.wrappedValue inside init() when the view is re-initialized? Does it return the live SwiftUI-managed state, or the freshly-constructed default?

This distinction matters — especially when you’re storing @Observable classes in @State and doing work in init().

The Setup

Consider a view that eagerly fetches data in init() to avoid a loading flash:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct ContentView: View {
    @State private var loader = DataLoader()

    init() {
        let loader = _loader.wrappedValue
        Task { @MainActor in
            await loader.fetch()
        }
    }

    var body: some View {
        if loader.hasFetched {
            Text("Loaded \(loader.items.count) items")
        } else {
            ProgressView()
        }
    }
}

This works on first render — DataLoader is created, the Task fetches data, hasFetched flips to true, and the view updates. But what happens when the parent re-renders and ContentView.init() runs again?

What the Docs Say

From Apple’s State documentation:

A State property always instantiates its default value when SwiftUI instantiates the view.

And from State.init(wrappedValue:):

SwiftUI initializes the state’s storage only once for each container instance that you declare.

So a new default value is constructed every time, but SwiftUI ignores it after the first creation. The question remains: when you read _state.wrappedValue inside init(), which one do you get?

The Test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Observable
final class Counter {
    var count = 0
    var id: String

    init() {
        self.id = String(UUID().uuidString.prefix(8))
        print("🔵 Counter.init — id: \(id), count: \(count)")
    }

    deinit {
        print("🔴 Counter.deinit — id: \(id), count: \(count)")
    }
}

struct ChildView: View {
    @State private var counter = Counter()
    let label: String

    init(label: String) {
        self.label = label
        let val = _counter.wrappedValue
        print("⚪️ ChildView.init — label: \(label), wrappedValue.id: \(val.id), wrappedValue.count: \(val.count)")
    }

    var body: some View {
        VStack {
            Text("\(label) — count: \(counter.count)")
            Text("id: \(counter.id)")
            Button("Increment") { counter.count += 1 }
        }
    }
}

struct ParentView: View {
    @State private var parentCount = 0

    var body: some View {
        VStack(spacing: 20) {
            Text("Parent count: \(parentCount)")
            Button("Re-render parent") { parentCount += 1 }
            ChildView(label: "child-\(parentCount)")
        }
        .padding()
    }
}

Steps:

  1. Launch the app
  2. Tap “Increment” a few times
  3. Tap “Re-render parent”
  4. Check the console

The Results

1
2
3
4
5
6
7
8
9
10
🔵 Counter.init — id: 2588864D, count: 0
⚪️ ChildView.init — label: child-0, wrappedValue.id: 2588864D, wrappedValue.count: 0
🔵 Counter.init — id: 2FD75261, count: 0
⚪️ ChildView.init — label: child-0, wrappedValue.id: 2FD75261, wrappedValue.count: 0
🔵 Counter.init — id: 0499F319, count: 0
⚪️ ChildView.init — label: child-1, wrappedValue.id: 0499F319, wrappedValue.count: 0
🔴 Counter.deinit — id: 2FD75261, count: 0
🔵 Counter.init — id: BC6AAD29, count: 0
⚪️ ChildView.init — label: child-2, wrappedValue.id: BC6AAD29, wrappedValue.count: 0
🔴 Counter.deinit — id: 0499F319, count: 0

Three observations:

  1. A new Counter is allocated on every init() call — each has a unique id (2588864D, 2FD75261, 0499F319, BC6AAD29).
  2. _counter.wrappedValue returns the throwaway, not the live state — the ids in ⚪️ always match the 🔵 line above it (the freshly-constructed default), never the original 2588864D. And count is always 0, even after incrementing.
  3. SwiftUI keeps the first instance and discards the rest — 2588864D is never deinit’d (SwiftUI kept it), while every subsequent Counter gets deallocated.

Why This Matters

If you capture _state.wrappedValue in a Task from init(), you’re operating on a throwaway object that SwiftUI will discard. The work you do — network calls, database queries, subscriptions — runs against an object nobody is listening to.

For value types stored in @State, this is mostly harmless — the throwaway is a cheap struct copy. But for @Observable classes, each throwaway is a real heap allocation that may set up subscriptions, start timers, or trigger I/O in its own init.

This also means guards like this won’t work:

1
2
3
4
5
6
7
init() {
    if !_loader.wrappedValue.hasFetched {
        // This always runs — wrappedValue.hasFetched is always
        // false because it's reading from the new default, not
        // the live state where the fetch already completed.
    }
}

The Fix

Don’t do side-effectful work in init() based on _state.wrappedValue. Use .task or .onAppear instead — these are tied to the view’s lifecycle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct ContentView: View {
    @State private var loader = DataLoader()

    var body: some View {
        Group {
            if loader.hasFetched {
                Text("Loaded \(loader.items.count) items")
            } else {
                ProgressView()
            }
        }
        .task {
            await loader.fetch()
        }
    }
}

If you need the fetch to complete before the view appears (to avoid a loading flash during a swipe animation, for example), consider having the parent pre-fetch the data and pass it in, or ensure the @Observable class’s init is lightweight and the fetch method is idempotent.

This post is licensed under CC BY 4.0 by the author.