Solving SwiftUI’s Optional Alert Binding Problem with ObjectObserver
If you have ever built an edit flow in SwiftUI using .alert(item:) or .sheet(item:) backed by an ObservableObject, you have almost certainly bumped into a frustrating wall: modifying a property in one TextField inside the alert doesn’t update another TextField or view sitting right next to it.
Here is a deep dive into why this happens, why modern solutions like @Bindable work (and their hidden performance trade-offs), and how a tiny, reusable view helper called ObjectObserver solves the problem cleanly without requiring single-use subview structs.
The Root Problem: Optionality and Property Wrappers
When driving a modal or alert dynamically from a data item, the standard SwiftUI pattern relies on an optional state property:
@State private var itemToEdit: EditItem?
Passing $itemToEdit into .alert("Edit", item: $itemToEdit) { item in ... } presents the alert whenever itemToEditbecomes non-nil and passes the unwrapped value into the view builder closure.
However, if your data model is an ObservableObject, you immediately hit architectural friction:
@StateObjectand@ObservedObjectdo not support optionality directly. You cannot declare@StateObject var itemToEdit: EditItem?in a way that cleanly drives.alert(item:).- Initializing a non-optional
@StateObjectpresents immediately. If you try to work around optionality by giving@StateObjecta default fallback instance, SwiftUI treats the state as non-nil from screen load, triggering unwanted presentations or forcing you back to boolean flags (isPresented).
The Inline Property Wrapper Trap
A common workaround developers try is instantiating a local property wrapper inside the alert closure:
.alert("Edit Item", item: $itemToEdit) { item in
// ❌ Fails to observe changes!
let $item = ObservedObject(wrappedValue: item).projectedValue
TextField("Title", text: $item.title)
TextField("Title Preview", text: $item.title) // Will NOT update as you type in the field above!
}
Why does this fail?
Property wrappers like @ObservedObject only work when declared as top-level properties on a SwiftUI View struct. When declared as a local variable inside a closure, SwiftUI’s rendering engine never registers a subscription to the object’s objectWillChange publisher. The values mutate on the underlying instance, but the alert view hierarchy never re-evaluates.
How iOS 17’s @Observable and @Bindable Work (And the Catch)
With Swift 5.9 and iOS 17, Apple introduced the @Observable macro and @Bindable property wrapper.
Using @Observable, you can write:
.alert("Edit Item", item: $itemToEdit) { item in
@Bindable var item = item
TextField("Title", text: $item.title)
TextField("Title Preview", text: $item.title)
}
This works because @Observable uses fine-grained access tracking instead of coarse objectWillChange broadcasts.
The Hidden Catch
When @Bindable accesses $item.title directly inside the inline alert closure, it registers a dependency on the parent view’s body. Every single keypress in the alert’s text field forces the entire parent view to re-evaluate its body.
Re-rendering large parent views (with complex lists, heavy graphics, or navigation stacks) just because a text field inside a temporary alert is updating introduces an unnecessary performance penalty.
The Standard Fix: Cumbersome Child Views
The traditional recommendation is to extract the alert contents into a dedicated subview struct:
struct ItemAlertContent: View {
@ObservedObject var item: EditItem // Top-level wrapper connects to SwiftUI view graph
var body: some View {
TextField("Title", text: $item.title)
TextField("Title Preview", text: $item.title)
Button("Cancel", role: .cancel) {}
}
}
While this works, creating a single-use struct for every alert or sheet in your application introduces massive boilerplate and splits your view logic across multiple declarations.
The Elegant Solution: ObjectObserver
ObjectObserver is a lightweight container view that bridges this gap seamlessly. It handles two jobs at once:
- Observes: Houses a genuine top-level
@ObservedObjectproperty wrapper, connecting directly to SwiftUI’s view graph so only the alert re-evaluates on change. - Yields Bindings: Passes
ObservedObject<Target>.Wrapperinto its closure, allowing you to access property bindings (wrapper.text) directly without custom wrapper abstractions.
The Implementation
import SwiftUI
/// A container view that observes an ObservableObject and yields its projected bindings.
struct ObjectObserver<Target: ObservableObject, Content: View>: View {
@ObservedObject var object: Target
private let content: (ObservedObject<Target>.Wrapper) -> Content
init(
_ object: Target,
@ViewBuilder content: @escaping (ObservedObject<Target>.Wrapper) -> Content
) {
self.object = object
self.content = content
}
var body: some View {
content($object)
}
}
Usage at the Call Site
Using ObjectObserver, your alert bindings become clean, reactive, and isolated to the alert itself. Because the closure yields ObservedObject<Target>.Wrapper, Xcode will automatically suggest wrapper as the parameter name:
.alert("Hi", item: $itemToEdit) { item in
ObjectObserver(item) { wrapper in
// Direct binding access via wrapper.text
TextField("Text", text: wrapper.text)
TextField("Testing live updates", text: wrapper.text) // Updates live across both fields!
Button("Cancel", role: .cancel) {}
}
}
Why This Wins
- No Extra Custom Types: Zero third-party abstractions—it uses Apple’s native
ObservedObject<Target>.Wrapperdirectly. - No Single-Use Structs: Keep your alert logic inline right where it’s presented.
- Isolated Re-renders: Edits inside the alert only trigger updates within
ObjectObserver, protecting the parent view from unnecessary body re-evaluations. - Clean & Direct: Accessing
wrapper.textdirectly returns aBinding<String>without needing local property wrapper hacks.