8 min read
Your @State Broke and Apple Called It an Improvement
Your @State Broke and Apple Called It an Improvement

You install Xcode 27, open a project that compiled fine yesterday, and hit this:

Variable 'self.name' used before being initialized

You look at the line. You didn’t touch name. You didn’t touch that initializer at all. The error points at a variable you’re not even assigning on the line it’s complaining about.

Here’s the thing: @State stopped being a property wrapper. It’s a macro now, and that changes what assigning to it means.


What Actually Changed

@State used to be a dynamic property — a property wrapper, in the sense you’ve been thinking about it since 2019. In Xcode 27 it’s a macro that synthesizes real backing storage on your view.

Apple’s framing of this is entirely positive, and it’s fair. From the WWDC26 SwiftUI session:

In the 2027 releases, for the first time, classes initialized and stored using State properties are now lazy, which means they will only be initialized once.

That’s a real fix for a real problem. Before this, writing @State private var store = StickerStore() ran that initializer on every view initialization. SwiftUI kept the first instance and threw the rest away. You were constructing objects for nothing, and if that constructor did anything expensive — opened a database, kicked off a network call — you were paying for it repeatedly.

The lazy behavior was backported to iOS 17 and aligned releases, so you get it as soon as you build with Xcode 27, whatever you’re targeting.

The tradeoff? The macro synthesizes real storage, and real storage follows Swift’s normal initialization rules.


Why the Error Names the Wrong Variable

This is the part that costs you twenty minutes.

struct ContentView: View {
    var name: String
    @State private var counter: Int = 0

    init(name: String) {
        self.counter = 42  // ❌ Variable 'self.name' used before being initialized
        self.name = name
    }

    var body: some View {
        Text("\(name): \(counter)")
    }
}

The error fires on the counter line and names name. That looks like a compiler bug. It isn’t.

Assigning to self.counter now goes through synthesized storage, which counts as using self. Swift won’t let you use self until every stored property is initialized. At that point name hasn’t been assigned yet — so the diagnostic is technically correct and practically useless. It’s reporting the property that isn’t ready, not the line that broke the rule.

Under the old property wrapper, this compiled. The assignment didn’t read self in a way the initialization checker cared about.

Key insight: the error isn’t about name. It’s about touching self too early, and name is just the first property the compiler finds unready.


The Fix That Looks Right and Isn’t

Your instinct is to reorder — set name first, then counter:

init(name: String) {
    self.name = name      // ⚠️ Compiles, but hides a pre-existing bug
    self.counter = 42
}

That compiles. Don’t do it.

Assigning to a @State property that already has a default value never worked the way you think. With = 0 at the declaration and = 42 in the init, body reads 0. The declaration wins. SwiftUI sets up state storage from the declaration’s initial value, and your init-time assignment lands somewhere that never gets read.

So the old code was already broken. It just failed silently, and the macro turned a silent wrong answer into a loud compiler error.

The actual fix is to pick one place for the value. If it’s a constant, put it at the declaration and delete the init assignment:

struct ContentView: View {
    var name: String
    @State private var counter: Int = 42  // ✅ One source of truth

    init(name: String) {
        self.name = name
    }
}

Or if the initial value genuinely depends on a parameter, drop the default and assign only in the init:

struct ContentView: View {
    var name: String
    @State private var counter: Int  // ✅ No default — init owns it

    init(name: String, startingAt start: Int) {
        self.name = name
        self.counter = start
    }
}

Both compile. Both do what they look like they do, which the original didn’t.


The @Observable Case

Where this gets more interesting is @Observable models, because that’s where the lazy initialization actually pays off.

@Observable
final class CounterViewModel {
    var count = 0
}

struct CounterView: View {
    // ✅ Runs once now, not on every parent re-evaluation
    @State private var viewModel = CounterViewModel()

    var body: some View {
        Button("Count: \(viewModel.count)") { viewModel.count += 1 }
    }
}

Before Xcode 27, every time the parent re-evaluated, that CounterViewModel() ran again and got discarded. Now it runs once, when SwiftUI sets up the storage.

This pattern is still wrong, though, and the macro doesn’t rescue it:

struct CounterView: View {
    @State private var viewModel: CounterViewModel

    init() {
        // ⚠️ Constructs a fresh model on every view recreation
        viewModel = CounterViewModel()
    }
}

Moving construction into the init opts you out of the lazy behavior entirely. You’re back to building an object on every recreation, with the added risk of stale data when the parent hands you new parameters and the state storage keeps the old model.

Key insight: the lazy win is attached to the declaration, not the property. Construct at the declaration or you don’t get it.


The Other One: ContentBuilder

While you’re fixing initializers, you may hit a second break from the same release. SwiftUI’s result builders unified under ContentBuilder, and builders no longer constrain their contents to conform to View. Type checking that used to resolve now goes ambiguous:

Text("Hello")
    .overlay(Color.blue.opacity(0.70).blendMode(.overlay))
    // ❌ Ambiguous use of 'opacity'

Trailing closure syntax disambiguates it:

Text("Hello")
    .overlay { Color.blue.opacity(0.70).blendMode(.overlay) }

This one you get paid for immediately. Per Apple, ContentBuilder is a substantial improvement in type-checking performance when building with Xcode 27 — and it works at any minimum deployment target, because it’s an evolution of ViewBuilder rather than a replacement you have to migrate to.


The Mental Model

What you wroteOld behaviorXcode 27
@State var x = 0, unused initInit ran on every view initRuns once, lazily
@State var x = 0 + x = 42 in initCompiled, body read 0Compiler error
@State var x + x = 42 in initCompiled, workedCompiles, works
@State var model: M built in initRebuilt every recreationStill rebuilt — no lazy win
.overlay(Style.chained())ResolvedAmbiguous, use a trailing closure

Two habits worth forming:

  1. Give a @State property exactly one initial value. Either at the declaration or in the init, never both. The compiler now enforces what was always true.
  2. Construct @Observable models at the declaration. That’s where the lazy initialization lives.

What You Actually Got

It’s easy to read this as Apple breaking your code and calling it a feature. That’s half right — the source break is real, and the diagnostic pointing at the wrong variable makes it worse than it needed to be.

But the code it breaks was already wrong. A @State property with two initial values has always silently used one and ignored the other, and finding that by reading body output at runtime is worse than finding it at compile time. The lazy initialization is a straight win you get for free, backported, on deployment targets you’re already shipping.

Fix the initializers once. You won’t think about it again.

Further Reading