Solving SwiftUI’s Optional Alert Binding Problem with ObjectObserver

Published by malhal on

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:

  1. @StateObject and @ObservedObject do not support optionality directly. You cannot declare @StateObject var itemToEdit: EditItem? in a way that cleanly drives .alert(item:).
  2. Initializing a non-optional @StateObject presents immediately. If you try to work around optionality by giving @StateObject a 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:

  1. Observes: Houses a genuine top-level @ObservedObject property wrapper, connecting directly to SwiftUI’s view graph so only the alert re-evaluates on change.
  2. Yields Bindings: Passes ObservedObject<Target>.Wrapper into 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.text directly returns a Binding<String> without needing local property wrapper hacks.
Categories: SwiftUI