SwiftUI onChange fires twice when filtering data from @Observable store

Hi all,

I’m running into a “double update” effect in SwiftUI when using the @Observable with @State. I’m trying to understand whether this is expected behavior, a misuse on my side, or a potential bug.

Setup

I have an observable store using the Observation macro:

@Observable
class AlbumStore {

    var albums: [Album] = [
        Album(id: "1", title: "Album 1", author: "user1"),
        Album(id: "2", title: "Album 2", author: "user1"),
        Album(id: "3", title: "Album 3", author: "user1"),
        Album(id: "4", title: "Album 4", author: "user1"),
        Album(id: "5", title: "Album 5", author: "user1"),
        Album(id: "6", title: "Album 6", author: "user1")
    ]

    func addAlbum(_ album: Album) {
        albums.insert(album, at: 0)
    }

    func removeAlbum(_ album: Album) {
        albums.removeAll(where: { $0 == album })
    }

}

In my view, I inject it via @Environment and also keep some local state:

@Environment(AlbumStore.self) var albumStore
@State private var albumToAdd: Album?

I derive a computed array that depends on both the environment store and local state:

private var filteredAlbums: [Album] {
    let albums = albumStore.albums.filter { album in
        if let albumToAdd {
            return album.id != albumToAdd.id
        } else {
            return true
        }
    }

    return albums
}

View usage

Inside a horizontal ScrollView / LazyHStack, I observe changes to filteredAlbums:

@ViewBuilder
private func carousel() -> some View {
    
    GeometryReader { proxy in
        let itemWidth: CGFloat = proxy.size.width / 3
        let sideMargin = (proxy.size.width - itemWidth) / 2
        
        ScrollView(.horizontal, showsIndicators: false) {
            LazyHStack(spacing: 20) {
                ForEach(filteredAlbums, id: \.id) { album in
                    albumItem(album: album)
                        .frame(width: itemWidth)
                        .scrollTransition(.interactive, axis: .horizontal) { content, phase in
                            content
                                .scaleEffect(phase.isIdentity ? 1.0 : 0.8)
                        }
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned(limitBehavior: .always))
        .scrollPosition(id: $carouselScrollID, anchor: .center)
        .contentMargins(.horizontal, sideMargin, for: .scrollContent)
        .onChange(of: filteredAlbums) { old, new in
            print("filteredAlbums id: \(new.map { $0.id })")
        }
        
    }
    
}

Triggering the update

When I add a new album, I do:

albumToAdd = newAlbum
albumStore.addAlbum(newAlbum)

Expected behavior Since filteredAlbums explicitly filters out albumToAdd, I expect the result to remain unchanged.

Actual behavior I consistently get two onChange callbacks, in this order:

filteredAlbums id: ["E852E42A-AAEC-4360-A6A6-A95752805E2E", "1", "2", "3", "4", "5", "6"]
filteredAlbums id: ["1", "2", "3", "4", "5", "6"]

This suggests:

  1. The AlbumStore update (albums.insert) is observed first.
  2. The @State update (albumToAdd) is applied later.
  3. As a result, filteredAlbums is recomputed twice with different dependency snapshots.

On a real iPad device, this also causes a visible scroll position jump. In the simulator, the jump is not visually observable; however, the onChange(of: filteredAlbums) callback still fires twice with the same sequence of values, indicating that the underlying state update behavior is identical.

Strange observations

  1. This does not happen with ObservableObject

If I replace @Observable with a classic ObservableObject + @Published:

class OBAlbumStore: ObservableObject {

    @Published var albums: [Album] = [
        Album(id: "1", title: "Album 1", author: "user1"),
        Album(id: "2", title: "Album 2", author: "user1"),
        Album(id: "3", title: "Album 3", author: "user1"),
        Album(id: "4", title: "Album 4", author: "user1"),
        Album(id: "5", title: "Album 5", author: "user1"),
        Album(id: "6", title: "Album 6", author: "user1")
    ]

    func addAlbum(_ album: Album) {
        albums.insert(album, at: 0)
    }

    func removeAlbum(_ album: Album) {
        albums.removeAll(where: { $0 == album })
    }
    
}

…and inject it with @EnvironmentObject, the double update disappears.

  1. Removing GeometryReader also avoids the issue

If I remove the surrounding GeometryReader and hardcode sizes:

@ViewBuilder
private func carousel() -> some View {
    
//        GeometryReader { proxy in
        let itemWidth: CGFloat = 400
        let sideMargin: CGFloat = 410
        
        ScrollView(.horizontal, showsIndicators: false) {
            LazyHStack(spacing: 20) {
                ForEach(filteredAlbums, id: \.id) { album in
                    albumItem(album: album)
                        .frame(width: itemWidth)
                        .scrollTransition(.interactive, axis: .horizontal) { content, phase in
                            content
                                .scaleEffect(phase.isIdentity ? 1.0 : 0.8)
                        }
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned(limitBehavior: .always))
        .scrollPosition(id: $carouselScrollID, anchor: .center)
        .contentMargins(.horizontal, sideMargin, for: .scrollContent)
        .onChange(of: filteredAlbums) { old, new in
            print("filteredAlbums id: \(new.map { $0.id })")
        }
        
//        }
    
}

…the double onChange no longer occurs.

Questions

  1. Is this update ordering expected when using @Observable and @State?
  2. Does Observation intentionally propagate environment changes before local state updates?
  3. Is GeometryReader forcing an additional evaluation pass that exposes this ordering?
  4. Is this a known limitation / bug compared to ObservableObject?

I want to understand why this behaves differently under Observation.

Thanks in advance for any insights 🙏

Full Project Link

This is a very interesting question, and you've done a great job of isolating the behavior and providing comparative examples. I’m not an expert on SwiftUI, wish I knew better, but that technology was always being really good at what you're seeing this "double update" with @Observable and @State, especially in conjunction with GeometryReader.

When @Published properties change, they emit events. Views that are observing these properties (via @StateObject or @EnvironmentObject) then get scheduled for re-evaluation. The update cycle is generally more straightforward: state change -> event -> view re-render.

filteredAlbums id: ["3878E7A7-CDAB-444F-8B8C-263763735770", "1", "2", "3", "4", "5", "6"]
filteredAlbums id: ["1", "2", "3", "4", "5", "6"]

You've triggered two state changes almost simultaneously:

  • albumStore.addAlbum(newAlbum): This is a change to a property managed by your @Observable AlbumStore.
  • albumToAdd = newAlbum: This is a change to a @State property within your view.

Your carousel() function reads from albumStore.albums. When albumStore.albums changes, your carousel() function (and specifically its filteredAlbums computed property) is re-evaluated. In this first pass, albumStore.albums has changed, but albumToAdd is still nil. So filteredAlbums is computed with the original albumStore.albums (which now has the new album added). Now, albumToAdd is not nil. The filteredAlbums computed property re-evaluates. It sees albumToAdd is set, so it filters out the album with that ID.

In this case, it is expected behavior in this specific scenario due to the interplay of two distinct state management systems reacting to changes. @Observable is designed for reactive updates, and when multiple dependencies change in close succession, you can see intermediate states, especially in computed properties. GeometryReader is notorious for invalidating its content more frequently.

In my opinion, the observed behavior is a result of @Observable’s fine-grained reactivity and GeometryReader’s layout behavior. These factors collectively expose the intermediate states that arise when multiple, nearly simultaneous state changes impact a computed property.

I am particularly interested in understanding this behavior and would appreciate insights from other developers, including the SwiftUI team. They may provide a more comprehensive explanation of the underlying mechanisms, as I am not as deeply immersed in this topic as I would like to be. While I generally allow SwiftUI to handle its tasks, I acknowledge the importance of understanding its inner workings.

Albert Pascual
  Worldwide Developer Relations.

@DTS Engineer Thanks a lot for the detailed explanation — that makes sense. It seems that when migrating directly from ObservableObject to @Observable, extra care is needed around update ordering and intermediate states.

In my case, this behavior leads to unexpected UI results, such as the scroll position ending up in an incorrect state (as shown in the screenshot above). I can consistently reproduce this on a real iPad Pro (M4) running iOS 26.2.1, where the UI visually jumps and then settles at an unintended position.

I’ll need to adjust my approach to work around this behavior. I’d also be very interested to hear more from the SwiftUI team on the intended patterns or best practices for avoiding these intermediate states in UI code.

SwiftUI onChange fires twice when filtering data from @Observable store
 
 
Q