- Jul 16
SwiftUI @State Is Now a Macro — What Actually Changed
- DevTechie
@State has a new shape. If you open the SwiftUI interface in the latest SDK, you will no longer find a property wrapper. You will find this:
@attached(accessor, names: named(init), named(get), named(set))
@attached(peer, names: prefixed(`_`), prefixed(__), prefixed(`$`))
macro State()
That looks like a much bigger change than it feels in actual SwiftUI code. This post walks through what the macro declaration tells us, the one practical behavior change that is easy to miss (lazy initialization for observable state), the pitfalls that did not go away, and how @State fits together with @Observable, @Binding, and @Bindable today.
The call site has not changed
You still write exactly what you wrote before:
import SwiftUI
struct PlayButton: View {
@State private var isPlaying = false
var body: some View {
Button(isPlaying ? "Pause" : "Play") {
isPlaying.toggle()
}
}
}
The rule is also the same: use @State for local state owned by a view, scene, or app. Initialize it where you declare it. Keep it private. Let SwiftUI manage the storage.
That last part is still the part people get wrong, so it is worth restating. A SwiftUI view is a value. SwiftUI can recreate that value many times — every time the parent's body runs, your view struct may be built again from scratch. The @State value is different. SwiftUI manages its storage separately and reconnects the view to that storage whenever it needs to evaluate body again.
So @State private var count = 0 is not just a stored property inside a struct. It is a request to SwiftUI: keep this value alive for this view identity.
Reading the macro declaration
The new declaration makes that relationship more visible if you know how to read attached macros.
@attached(accessor, names: named(init), named(get), named(set))
@attached(peer, names: prefixed(`_`), prefixed(__), prefixed(`$`))
macro State()
Two roles are attached here:
accessor— the macro can generateinit,get, andsetaccessors for the property it is attached to. Reads and writes to your property get routed through generated logic instead of plain stored-property access.peer— the macro can create related declarations next to your property, with names prefixed by_,__, and$.
That should look familiar. With property wrappers, we already had the idea of backing storage and projected values:
@State private var count = 0
// mental model:
count // wrapped value
$count // binding (projected value)
_count // backing storage
SwiftUI can now express State through the macro system while preserving the exact syntax developers already use. count still reads and writes the stored value. $count still gives you a Binding<Int>.
One compatibility note from the docs worth knowing before you go looking for this in your own project: when you build with Xcode 26 or earlier, SwiftUI uses the State property wrapper instead. The macro implementation only kicks in with the newer toolchain.
The practical win: lazy initialization for observable state
There is one behavior change that is easy to miss if you only look at the syntax, and it is the best reason to care about any of this.
This line looks the same as it always did:
@State private var store = StickerStore()
But with the macro implementation, SwiftUI can initialize the default value lazily. For an observable class stored in @State, the initializer runs when SwiftUI creates the state storage for the view — and it does not run again just because the view struct was recreated by its parent.
Here is why that matters. Before this change, this pattern could do more work than it looked like it did:
import SwiftUI
import Observation
@Observable
final class ViewModel {
var index = 0
init() {
print("ViewModel init")
}
}
struct CounterView: View {
@State private var viewModel = ViewModel()
var body: some View {
Button("Index: \(viewModel.index)") {
viewModel.index += 1
}
}
}
The state value was always preserved by SwiftUI — your count never reset. But the default value expression ViewModel() could still run every time SwiftUI recreated the view struct, only to have the result thrown away in favor of the existing storage. If the initializer did real work — created subscriptions, started observation, allocated caches, touched disk — that work happened far more often than expected.
With the macro version of @State, the model is created once for the lifetime of that view's state storage. Run the example above under the new toolchain and ViewModel init prints once, no matter how often the parent re-renders.
The optional-state workaround you can delete
Because of the old behavior, a common defensive pattern was to make the state optional and create the model in a task:
struct ContainerView: View {
@State private var viewModel: ViewModel?
var body: some View {
Group {
if let viewModel {
CounterContentView(viewModel: viewModel)
} else {
ProgressView()
}
}
.task {
viewModel = ViewModel()
}
}
}
struct CounterContentView: View {
var viewModel: ViewModel
var body: some View {
Button("Index: \(viewModel.index)") {
viewModel.index += 1
}
}
}
That workaround still has its place when the model genuinely needs to be created from async work, or refreshed when its input changes. But if the only reason you reached for it was to avoid repeated default initialization of an @Observable model, the new @State behavior removes that ceremony entirely. Declare the model inline and move on:
@State private var viewModel = ViewModel()
Pitfall: assigning @State in the view initializer
One caveat did not go away, and it is worth being loud about. Assigning to @State inside a view's initializer is still different from using a default value:
struct MyView: View {
@State private var viewModel: ViewModel
init() {
viewModel = ViewModel()
}
var body: some View {
Button("Index: \(viewModel.index)") {
viewModel.index += 1
}
}
}
The view initializer can still run many times — once per parent body evaluation. So the model initializer can still run many times too. SwiftUI may keep the original state storage and ignore later assignments for the purpose of preserving state, but your initializer side effects have already happened by then. Lazy default-value initialization does not save you here, because you opted out of the default value.
This becomes actively dangerous when the model depends on input from the parent:
struct DetailView: View {
@State private var viewModel: DetailViewModel
init(id: Item.ID) {
viewModel = DetailViewModel(id: id)
}
var body: some View {
Text(viewModel.title)
}
}
If id changes, this code does not automatically mean the state model is now in sync with the new input. The original storage — built from the first id — wins. For dependency-driven models, you still need to think in SwiftUI terms: pass the dependency directly into body, derive state from it, or use task(id:) when the model has to be recreated for a new identity:
struct DetailView: View {
let id: Item.ID
@State private var viewModel: DetailViewModel?
var body: some View {
Group {
if let viewModel {
Text(viewModel.title)
} else {
ProgressView()
}
}
.task(id: id) {
viewModel = DetailViewModel(id: id)
}
}
}
So the rule gets a little sharper with the macro version:
// Good: a model owned by this view, created once, lazily.
@State private var store = Store()
// Still suspicious: parent input can change, state won't follow.
init(id: Item.ID) {
self.store = Store(id: id)
}
The macro makes the common case better. It does not turn @State into a general dependency injection mechanism.
What the macro generates under the hood
If you expand the new @State macro in Xcode, the generated code looks less dramatic than the release note sounds. For a property like:
@State private var title = ""
the expansion still contains the familiar pieces: backing storage, wrappedValue, projectedValue, and the $title binding projection. Here is a simplified version of the expansion — the real output contains mangled generated names and internal attributes, but the important pieces are visible:
@State private var title = ""
// Macro-generated accessors for `title`
{
get {
__title.wrappedValue
}
nonmutating set {
__title.wrappedValue = newValue
}
}
// Macro-generated backing storage
private var __title = SwiftUICore.State._makeStorage {
""
}
// Property-wrapper-compatible storage, so `_title` still works
@SwiftUICore._StatePropertyWrapperStorage(initialValue: "")
private var _title: SwiftUICore.State<String>! = SwiftUICore._stateNil {
SwiftUICore.State(initialValue: "")
}
// Projection, so `$title` still gives you a Binding<String>
@SwiftUICore._StateProjectedValue
private var $title = SwiftUICore.State(initialValue: "").projectedValue
// Lazy initial stored value, evaluated when storage is created
@SwiftUICore._StateInitialStoredValue("")
private static var _initialStoredValue =
SwiftUICore.State._makeStorage(initialValue: "")
Read the pieces and the design intent becomes clear:
The accessor block explains how reading and writing
titleroutes through generatedget/setlogic into the backing storage.The peer declarations (
__title,_title,$title) explain why the compiler can still synthesize the backing storage and the projected value with the names you already know.The lazy initial stored value is where the practical win lives: the default value expression is captured as a closure and evaluated when SwiftUI creates the storage — not every time the struct is built.
So when the expansion shows wrappedValue and projectedValue, the right reading is: Apple kept the existing mental model and moved the implementation behind it. @State is macro-based at the declaration level, but it behaves like the @State you already know at the call site — with one payoff: default initialization of class-based observable state no longer runs every time the view struct is recreated.
How @State fits with @Observable today
Apple's documentation shows storing an @Observable object in @State exactly as before:
import SwiftUI
import Observation
@Observable
class Library {
var name = "My library of books"
}
struct ContentView: View {
@State private var library = Library()
var body: some View {
LibraryView(library: library)
}
}
The ownership rule: if the view creates an observable model and owns its lifetime, store the reference in @State. Then pass the object reference down to child views as a plain property:
struct LibraryView: View {
var library: Library
var body: some View {
Text(library.name)
}
}
You do not need a binding to mutate object properties
This trips up a lot of developers coming from ObservableObject. If a child view receives an observable object reference, it can mutate its properties directly — no @Binding, no $ anywhere:
@Observable
class Book {
var title = "Sample Book Title"
var isAvailable = true
}
struct BookCheckoutView: View {
var book: Book
var body: some View {
Button(book.isAvailable ? "Check out book" : "Return book") {
book.isAvailable.toggle()
}
}
}
When you do need @Binding
A binding to the object is for a different operation: when the child needs to replace the reference itself stored in the parent's state:
struct ContentView: View {
@State private var book: Book?
var body: some View {
Group {
if book != nil {
DeleteBookView(book: $book)
} else {
Text("No book")
}
}
.task {
book = Book()
}
}
}
struct DeleteBookView: View {
@Binding var book: Book?
var body: some View {
Button("Delete book") {
book = nil
}
}
}
Mutating book.title and setting book = nil are not the same kind of state change. The first mutates a property on a reference the parent still owns. The second replaces what the parent's state points at.
When you need @Bindable
If a child view needs a binding to a specific property of an observable object — say, to feed a TextField — use @Bindable:
struct BookEditorView: View {
@Bindable var book: Book
var body: some View {
TextField("Title", text: $book.title)
}
}
That gives us the cleanest summary of the whole ownership model:
// This view owns local value state.
@State private var count = 0
// This view owns an Observable reference.
@State private var book = Book()
// This child can replace the parent's value or reference.
@Binding var book: Book?
// This child needs bindings to properties of an Observable object.
@Bindable var book: Book
Macros are not free
One honest note to close on. Macros have their own cost. Anyone who has used custom macros in a real project knows that tooling and compile times are part of the story. A macro can remove boilerplate from source code and still move complexity into the build. Expansion happens at compile time, debugger stepping through generated code is not always pleasant, and macro-heavy targets build slower.
Apple controls both the macro and the compiler here, so @State is likely to be a well-optimized case. But "it's a macro now" is an implementation trade-off, not a free win — and it is fair to keep that in mind when the same technique gets pitched for your own APIs.
Wrap-up
The key takeaways:
@Stateis now declared as an attached macro withaccessorandpeerroles, replacing the property wrapper implementation. With Xcode 26 or earlier, the property wrapper is still used.Nothing changes at the call site.
titlereads and writes the value,$titlegives you aBinding,_titlestill refers to backing storage. Your existing code and mental model carry over.The real win is lazy initialization. The default value of an
@Stateproperty — including an@Observableclass instance — is now created once, when SwiftUI creates the state storage, not every time the parent recreates the view struct. The optional-state-plus-taskworkaround for avoiding repeated allocations is no longer needed for that reason alone.The initializer pitfall remains. Assigning
@Stateinside a view'sinitstill runs on every struct creation, and state built from parent input still goes stale when that input changes. Usetask(id:)or derive from the dependency directly.The ownership rules are unchanged:
@Statefor what a view owns (values or@Observablereferences), plain properties to pass references down,@Bindingto replace a parent's value or reference,@Bindablefor bindings into an observable object's properties.
Same spelling, same rules, better behavior in the common case. That is about the best kind of framework change there is.