SwiftUI onChange fires twice when filtering data from @Observable store
05:18 05 Feb 2026

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

swiftui observation