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
Stateproperty 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:
- Launch the app
- Tap “Increment” a few times
- Tap “Re-render parent”
- 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:
- A new
Counteris allocated on everyinit()call — each has a unique id (2588864D,2FD75261,0499F319,BC6AAD29). _counter.wrappedValuereturns the throwaway, not the live state — the ids in⚪️always match the🔵line above it (the freshly-constructed default), never the original2588864D. Andcountis always0, even after incrementing.- SwiftUI keeps the first instance and discards the rest —
2588864Dis never deinit’d (SwiftUI kept it), while every subsequentCountergets 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.