Skip to content

Instantly share code, notes, and snippets.

@niw
Created November 6, 2025 05:47
Show Gist options
  • Select an option

  • Save niw/abc4ea8e5b19b16e005d5fbc8548f74f to your computer and use it in GitHub Desktop.

Select an option

Save niw/abc4ea8e5b19b16e005d5fbc8548f74f to your computer and use it in GitHub Desktop.
Unsafe `Binding` access on SwiftUI

Unsafe Binding Access on SwiftUI

It’s not always safe to access a Binding within a callback that isn’t part of SwiftUI’s event system. An example is illustrated in the following sample code:

extension Notification.Name {
    static let event = Notification.Name("event")
}

struct Item: Identifiable {
    var id: Int
    var name: String
}

struct ItemView: View {
    @Binding
    var item: Item

    var body: some View {
        TextEditor(text: $item.name)
            // This is a non-SwiftUI event callback.
            .onReceive(NotificationCenter.default.publisher(for: .event)) { _ in
                // 3. This block is called immediately in the same call from `NotificationCenter.default.post`.
                // 4. This `item` references `items[0]`, which crashes the app due to an index out of bounds.
                print(item)
            }
    }
}

struct MainView: View {
    @State
    private var items: [Item] = [
        Item(id: 1, name: "item")
    ]

    var body: some View {
        VStack {
            ForEach($items) { $item in
                ItemView(item: $item)
            }
        }

        Button("Clear") {
            // 1. `items` is cleared.
            items = []
            // 2. Before `body` is updated, post the notification.
            NotificationCenter.default.post(name: .event, object: nil)
        }
    }
}

First of all, the right approach is to avoid using non-SwiftUI event callbacks in SwiftUI-based applications when they access SwiftUI states.

However, sometimes it’s unavoidable. To work around this situation, it’s necessary to ensure events are received only after the body has been updated. The timing of body updates differs depending on the platform.

On iOS, it occurs immediately within the source event call, such as __CFRunLoopRunSource0 or __CFRunLoopRunSource1. Therefore, the following change can delay the callback safely:

.onReceive(
    NotificationCenter.default.publisher(for: .event)
        .receive(on: RunLoop.main)
) { _ in
    // ...

This works because RunLoop.main schedules the call on the next __CFRunLoopDoBlock, which always happens after any source event calls in __CFRunLoopRun.

However, on macOS, body updates happen not within __CFRunLoopRun, but in __CFRunLoopDoObserver for kCFRunLoopExit.

This is because NSApplication handles system events within its own NSApplication.run loop using NSEvent, and runs NSRunLoop alongside it.
Therefore, RunLoop.main alone is not enough to delay the callback.

It’s tricky, but adding a new CFRunLoopObserver at the end of kCFRunLoopExit can watch for that moment. RunOnMainRunLoopExitScheduler is an example implementation.

.onReceive(
    NotificationCenter.default.publisher(for: .event)
        .receive(on: RunOnMainRunLoopExitScheduler())
) { _ in
    // ...

With this change, the call order would look like this:

Button("Clear") {
    // 1. `__CFRunLoopRunSource0` or `__CFRunLoopRunSource1`,
    //    a touch event calls this block (or on macOS, a mouse event).
    // 2. `items` is cleared.
    items = []
    // 3. Before `body` is updated, post the notification.
    NotificationCenter.default.post(name: .event, object: nil)
}

.onReceive(
    // 4. Notification is delivered here.
    NotificationCenter.default.publisher(for: .event)
        // 5. Delayed until the next `__CFRunLoopDoBlock` call,
        //    after `__CFRunLoopRunSource0` finishes.
        .receive(on: RunLoop.main)
        //    Or, on macOS, delayed until the end of the next
        //    `__CFRunLoopRunObserver(kCFRunLoopExit)`.
        .receive(on: RunOnMainRunLoopExitScheduler())
) { _ in
    // ...

var body: some View {
    // 6. `body` is updated within the same call from the touch event,
    //    or during `__CFRunLoopRunObserver(kCFRunLoopExit)`
    //    in the call following a mouse `NSEvent` on macOS.
    VStack {
        // `items` are gone.
        ForEach($items) { $item in
            // Therefore, `ItemView` disappears.
            ItemView(item: $item)
        }
    }

// 7. Since `ItemView` has disappeared,
//    this publisher seems to be cancelled and gone.
.onReceive(
    NotificationCenter.default.publisher(for: .event)

Lazy Container Views

There’s an even more complicated situation when such Binding access happens inside lazy container views such as LazyVStack, LazyHStack, or List.

LazyVStack {
    ForEach($items) { $item in
        ItemView(item: $item)
    }
}

In this case, surprisingly, even after body is updated and ItemView disappears, the onReceive handler may still be called.


To detect this, an additional visibility check is required.

struct ItemView: View {
    @Binding
    var item: Item
    @State
    private var isAppeared: Bool = false

    var body: some View {
        TextEditor(text: $item.name)
            .onAppear {
                isAppeared = true
            }
            .onDisappear {
                isAppeared = false
            }
            // This is a non-SwiftUI event callback.
            .onReceive(
                NotificationCenter.default.publisher(for: .event)
                    .receive(on: RunLoop.main) // for iOS
                    .receive(on: RunOnMainRunLoopExitScheduler()) // for macOS
            ) { _ in
                guard isAppeared else {
                    return
                }
                print(item)
            }
    }
}

Then, the call order would be as follows:

Button("Clear") {
    // 1. `__CFRunLoopRunSource0` or `__CFRunLoopRunSource1`,
    //    touch event calls this block, or on macOS, a mouse event.
    // 2. `items` is cleared.
    items = []
    // 3. Before `body` is updated, post the notification.
    NotificationCenter.default.post(name: .event, object: nil)
}

.onReceive(
    // 4. Notification is delivered here.
    NotificationCenter.default.publisher(for: .event)
        // 5. Delayed until the next `__CFRunLoopDoBlock`,
        //    after `__CFRunLoopRunSource0` ends.
        .receive(on: RunLoop.main)
        //    Or, on macOS, delayed until the end of the next
        //    `__CFRunLoopRunObserver(kCFRunLoopExit)`.
        .receive(on: RunOnMainRunLoopExitScheduler())
) { _ in
    // ...

var body: some View {
    // 6. `body` is updated within the same call from the touch event,
    //    or within `__CFRunLoopRunObserver(kCFRunLoopExit)`
    //    in the next call after the mouse `NSEvent` on macOS.
    VStack {
        // `items` are now gone.
        ForEach($items) { $item in
            // Therefore, `ItemView` disappears.
            ItemView(item: $item)
        }
    }

// 7. Since `ItemView` disappeared, `onDisappear` is called.
.onDisappear {
    isAppeared = false
}

// 8. Within a lazy view, the publisher is not cancelled immediately,
//    so the delayed callback still executes during `__CFRunLoopDoBlock`
//    or `__CFRunLoopRunObserver(kCFRunLoopExit)`.
.onReceive(
    NotificationCenter.default.publisher(for: .event)
        .receive(on: RunLoop.main) // on iOS
        .receive(on: RunOnMainRunLoopExitScheduler()) // on macOS
) { _ in
    // 9. Since `onDisappear` has been called and
    //    `isAppeared` is now false, guard and return.
    guard isAppeared else {
        return
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment