Skip to content

SwiftUI: @State is a macro

Published: at 12:04 PM

Introduction

Apple quietly rewired one of SwiftUI’s most fundamental building blocks. With Xcode 27, @State is no longer a property wrapper - it’s a Swift macro. The call site looks identical. The behavior for most code is indistinguishable. But under the hood, the implementation changed in ways that matter, and one of those changes fixes a subtle performance problem that has been lurking in SwiftUI apps since iOS 13.

Table of contents

Open Table of contents

The New Declaration

If you open the updated documentation, you’ll see this:

@attached(accessor, names: named(init), named(get), named(set)) 
@attached(peer, names: prefixed(`_`), prefixed(__), prefixed(`$`))
macro State()

Previously @State was a @propertyWrapper struct. Now it’s two attached macros: one generates the property accessors (get, set, init), the other generates the peer declarations - the backing storage _count, the internal storage __count, and the projected value $count.

The code you write doesn’t change at all:

@State private var count = 0

count     // wrappedValue
$count    // Binding<Int>
_count    // State<Int> storage

All three still work exactly as before.

Why Apple Did This

The property wrapper version of @State had a subtle but real flaw: the default value expression ran every time the view struct was initialized, not just once when state storage was first created.

SwiftUI can re-create view structs frequently any time a parent’s body runs. The state value itself was preserved, but expressions like MyViewModel() in a default value would still execute. SwiftUI would immediately discard the result and reconnect the view to its existing storage, but the side effects of that initializer had already happened. Allocations, disk reads, observer registrations - all of it, repeatedly, for nothing.

// With the old property wrapper — MyViewModel() could run many times
struct MyView: View {
    @State private var viewModel = MyViewModel()
}

This was a known footgun. A common workaround was to use an optional and initialize inside .task:

// Old workaround to avoid repeated initialization
struct MyView: View {
    @State private var viewModel: MyViewModel?

    var body: some View {
        Content(viewModel: viewModel)
            .task { viewModel = viewModel ?? MyViewModel() }
    }
}

Macros fix this at the language level. The macro wraps the default value in a closure and passes it to State._makeStorage, which evaluates it lazily once, when storage is created for the first time:

// What the macro generates under the hood
private var __viewModel = SwiftUICore.State._makeStorage {
    MyViewModel() // runs exactly once
}

The workaround pattern above is no longer needed. You can write the simple version, and it will behave the way you always expected.

What About Backward Compatibility

This is a compile-time change, not a runtime one. The macro expands into code that calls the same SwiftUI runtime APIs that have existed since iOS 17. Your deployment target doesn’t need to change, and existing code requires no modifications.

Xcode 27 uses the macro implementation. Earlier versions of Xcode continue to use the property wrapper. The result at runtime is identical minus the lazy initialization fix, which requires the macro version.

One Caveat That Didn’t Change

Lazy initialization applies only to default values in the property declaration. Assigning to @State inside a view’s init still runs every time the struct is initialized:

struct MyView: View {
    @State private var viewModel: MyViewModel

    init(id: String) {
        // ⚠️ Still runs on every struct re-creation
        viewModel = MyViewModel(id: id)
    }
}

SwiftUI may keep the original storage and ignore later assignments, but your initializer side effects have already fired. For state that depends on parent input, task(id:) remains the right tool:

struct MyView: View {
    let id: String
    @State private var viewModel: MyViewModel?

    var body: some View {
        Content(viewModel: viewModel)
            .task(id: id) {
                viewModel = MyViewModel(id: id)
            }
    }
}

Bottom Line

The change from property wrapper to macro is the most significant internal redesign of @State since SwiftUI launched. It enables lazy initialization of default values, which fixes a long-standing performance issue with @Observable objects stored in @State. Your existing code works as-is, but new code using @State private var model = MyModel() is now correct by default — no workarounds needed.

Apple moving @State to macros also signals where SwiftUI’s implementation is heading: toward Swift’s macro system rather than compiler built-ins, which gives both Apple and the community more explicit control over code generation.

Thanks for reading

If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content.


avatar

Nikita Vasilev

A software engineer with over 8 years of experience in the industry. Writes this blog and builds open source frameworks.


Next Post
SwiftUI: Observable macro under the hood