The unexpected @Binding side effect

Tue, 20 Jan 2026 00:00:00 +0000

How switching from a get-only Binding to a let variable might not be as innocent as you think

I recently stumbled upon a view defining a @Binding while never using the setter. Pretty much useless, don't you think?

Sure enough I just replaced it with a simple let. If we never use the setter, nothing could go wrong, right? Well of course... it did.

But why and how? Once stated it actually makes sense but this is something you probably didn't realise until now.

The scenario

Let's grab the culprit code: a simple view having a Boolean binding. When isAddingCard changes from true to false, the view gets animated.

struct CompetitionCardCollectionSlotView: View {
    let slot: PlayerCardCollectionSlot
    @Binding var isAddingCard: Bool

    @State var scaleEffect = CGFloat(1.0)
    @State var opacity = CGFloat(1.0)
    @State var brightness = CGFloat(0.0)

    var body: some View {
        cardView
            .onAppear(perform: {
                if isAddingCard {
                    scaleEffect = 1.3
                    opacity = 0
                    brightness = 0.7
                }
            })
            .onChange(of: isAddingCard) { oldValue, newValue in
                if oldValue, !newValue {
                    opacity = 1
                    withAnimation(.timingCurve(0.54, 0.05, 0.37, 0.98, duration: 0.3)) {
                        scaleEffect = 1
                        brightness = 0
                    }
                }
            }
        }
    }
}

#Preview {
    @Previewable @State var isAnimated = true

    VStack {
        CompetitionCardCollectionSlotView(slot: ..., isAddingCard: $isAnimated)
        Button(action: { isAnimated = false }) { Text("Start animation") }
    }
}

If we replace the binding with a simple let and try our preview... everything works 💪. So no issue for now.

What becomes interesting is where and how this view is used inside our codebase:

struct CompetitionCardCollection: View {
    let slots: [PlayerCardCollectionSlot]
    let addedCardSlots: [PlayerCardCollectionSlot]
    let startAnimation: Bool

    @State private slotsAnimated: [PlayerCardCollectionSlot.ID: Bool] = [:]

    var body: some View {
        ScrollView {
            VStack {
                ForEach(slots) { slot in
                    cardView(for: slot)
                }
            }
        }
        .onAppear {
            prepareAnimations()
        }
        .onChange(of: startAnimation) {
            if startAnimation {
                startAnimation()
            }
        }
    }

    @ViewBuilder
    func cardView(for slot: PlayerCardCollectionSlot) -> some View {
        let binding = Binding<Bool>(
            get: { slotsAnimated[slot.id, default: false] },
            set: { slotsAnimated[slot.id] = $0 }
        )
        CompetitionCardCollectionSlotView(slot: playerSlot, isAddingCard: binding)
    }

    func prepareAnimations() {
        for slot in addedCardSlot {
            slotsAnimated[slot.id] = true
        }
    }

    func startAnimation() {
        // we turn each slotsAnimated to false over time
        slotsAnimated[slot.id] = false
    }
}

This is an oversimplified version of the actual code but the global idea is:

1. First, we render a list of slots. For each slot, we create a binding for isAddingCard using an attribute named slotsAnimated

2. Inside onAppear, we prepare for the animation by filling slotsAnimated with the proper initial state

meaning some slots will change from false to true 3. When startAnimation turned true, we start the animation

Now if we replace isAddingCard: binding with just slotsAnimated[slot.id, default: false]... the animation does not work anymore 😦

We could argue about how CompetitionCardCollection animation is handled, whether we use the right default value and so on. But what strikes me here is: I changed a useless @Binding by a simple let... and it broke my code. WHY?

Time to investigate 🕵🏻‍♂️

Debugging

As any good developer, best approach is by using my good old friend print 😜. Let's first see what's happening inside the view rendering cycle methods and look if we see any difference between the two implementations:

cardView
    .onAppear {
        print("[onAppear]")
        print("[onAppear] isAddingCard = \(isAddingCard)")
    }
    .onChange(of: isAddingCard) { oldValue, newValue in
        print("[onChange] \(oldValue) -> \(newValue)")
    }

// With @Binding
[onAppear]
[onAppear] isAddingCard = true
[onChange] false -> true

// With let
[onAppear]
[onAppear] isAddingCard = false
[onChange] false -> true

Notice how we don't get the same value inside onAppear. There's definitely something going on. Did you also notice that, when using Binding, isAddingCard is already true inside onAppear and yet onChange is called. How is it possible?

Well there's one missing log we could add and would unravel the whole mystery: inside the binding.

// with binding
let binding = Binding<Bool>(
    get: {
        print("[getter] \(slotsAnimated[slot.id, default: false])")
        return slotsAnimated[slot.id, default: false]
    },
    set: { slotsAnimated[slot.id] = $0 }
)
CompetitionCardCollectionSlotView(slot: playerSlot, isAddingCard: binding)

// with no binding
// using a closure so we can use print inside a view
let isAddingCard = {
    print("[getter] \(slotsAnimated[slot.id, default: false])")
    return slotsAnimated[slot.id, default: false]
}

CompetitionCardCollectionSlotView(slot: playerSlot, isAddingCard: isAddingCard())

// With binding
[getter] false
[onAppear]
[getter] true
[onAppear] isAddingCard = true
[getter] true
[onChange] false -> true

// With no binding
[getter] false
[onAppear]
[onAppear] isAddingCard = false
[getter] true
[onChange] false -> true

Now everything becomes crystal-clear 🔮:

inside onAppear using Binding, requesting isAddingCard we're effectively calling a closure which returns the current value. Hence we're getting true because the value had changed since the view rendering was planned.

Using let, we get a snapshot so the value inside onAppear is the one the view was created with, which is the value we'd "expect".

So Binding has a (subtle) side effect I didn't realize until now which might lead to get a value sooner (inside the rendering cycle) than expected!

What is AI saying about it?

It's AI era (it seems?) and so you may be thinking "couldn't we have solved it using AI?" 🤖. I believe it's interesting to first look by yourself then asking AI if you failed to find the reason or just want a counter argument.

And so was AI able to understand the issue? Here was my prompt:

I found a strange behaviour inside CompetitionCardCollectionSlotView:

- if I replace isAddingCard binding by a simple bool value, then the animation inside CompetitionCardCollectionView doesn't work anymore.

Can you understand and explain the reason why the logs are different? What's the reason?

I tested twice: only the prompt, and the prompt + logs. In first case the answer seemed a little bit clunky while for the later AI provided a far more decent response:

### With Binding (Working Animation)

When you use a `Binding<Bool>` with a custom `get` closure:

1. SwiftUI calls `get` during view evaluation to establish dependencies.
2. The multiple `get` calls you see are SwiftUI building its dependency graph.
3. When `slotsAnimated[slot.id]` changes, SwiftUI detects it and re-evaluates the view.
4. `onAppear` sees `true` because the binding reads the current value at that moment.
5. `onChange` fires because the binding’s value changes from `true` to `false`.

### With Simple Bool (Broken Animation)

When you use a simple bool value (line 243: `let isAddingCard = slotsAnimated[slot.id]`):

1. The value is captured once when the view is created.
2. SwiftUI doesn’t track changes to `slotsAnimated[slot.id]` because it’s not a tracked dependency.
3. `onAppear` sees `false` because it reads the initial captured value.

Which is what we also concluded from our test. HOWEVER it came with the worst advice possible: "Keep using the binding pattern" 🔫.

Conclusion

This was super interesting to debug because it made me realize one thing which was implicit until now: when using a Binding, the value you get may vary during the rendering cycle. Which I think might lead to some subtle bugs from time to time?

I guess that would be a good reason to prefer having two variables over a Binding: a plain let attribute along a setter closure.