Fix: SwiftUI Child View Changes Not Propagating to Parent State Array

Fix: SwiftUI Child View Changes Not Propagating to Parent State Array

Written by

in

Quick answer: The issue occurs because PuzzlePieceView uses @State with a struct, creating a copy. Changes to currentPosition only affect the copy. Fix by changing @State to @Binding and passing bindings via ForEach($pieces).

Why this happens

SwiftUI’s @State property wrapper creates a source of truth for a value type. When you pass a struct to a child view marked with @State, SwiftUI makes a copy of that struct for the child. Any mutations inside the child — such as updating piece.currentPosition — only modify the child’s private copy. The original array in the parent view (PuzzleView) remains unchanged.

In the puzzle game code, PuzzlePieceView declares @State var piece: PuzzlePiece. Because PuzzlePiece is a struct (a value type), the child view owns an independent instance. The drag gesture updates piece.currentPosition on that instance, but PuzzleView‘s pieces array still holds the original positions. Consequently, checkPuzzleCompletion() sees stale data and isCompleted never becomes true.

This is a classic SwiftUI pitfall: @State is designed for local view state, not for sharing mutable state up the view hierarchy. For shared mutable state, you need a binding.

How to fix it

1. Replace @State with @Binding in the child view

Change PuzzlePieceView to accept a binding instead of owning its own state:

struct PuzzlePieceView: View {
    @Binding var piece: PuzzlePiece
    let pieceSize: CGFloat
    let puzzleAreaSize: CGSize
    let onPositionChanged: () -> Void

    @GestureState private var dragOffset = CGSize.zero

    var body: some View {
        Image(uiImage: piece.image.asUIImage())
            .resizable()
            .frame(width: pieceSize, height: pieceSize)
            .position(
                x: piece.currentPosition.x + dragOffset.width,
                y: piece.currentPosition.y + dragOffset.height
            )
            .gesture(
                DragGesture()
                    .updating($dragOffset) { value, state, _ in
                        state = value.translation
                    }
                    .onEnded { value in
                        let newX = piece.currentPosition.x + value.translation.width
                        let newY = piece.currentPosition.y + value.translation.height

                        // Snap to grid logic here...
                        piece.currentPosition = CGPoint(x: snappedX, y: snappedY)
                        onPositionChanged()
                    }
            )
    }
}

Key changes:
– @Binding var piece: PuzzlePiece — the view no longer owns the data; it holds a reference to the parent’s storage.
– Mutating piece.currentPosition now writes directly to the element inside PuzzleView‘s pieces array.

2. Pass bindings from the parent using ForEach($pieces)

Update puzzlePiecesView in PuzzleView to iterate over the projected value of the @State array:

private func puzzlePiecesView(geometry: GeometryProxy) -> some View {
    ForEach($pieces) { $piece in
        PuzzlePieceView(
            piece: $piece,
            pieceSize: pieceSize,
            puzzleAreaSize: CGSize(
                width: pieceSize * CGFloat(gridSize),
                height: pieceSize * CGFloat(gridSize)
            ),
            onPositionChanged: checkPuzzleCompletion
        )
        .offset(
            x: geometry.size.width / 2 - pieceSize * CGFloat(gridSize) / 2,
            y: geometry.size.height / 2 - pieceSize * CGFloat(gridSize) / 2
        )
    }
}

$pieces (the projected value of @State private var pieces) yields a Binding<PuzzlePiece> for each element. The ForEach syntax ForEach($pieces) { $piece in } (available since Swift 5.7 / iOS 16) binds each iteration variable directly, so you can pass $piece to the child.

After these two changes, dragging a piece updates the same PuzzlePiece instance that lives in the parent’s array. checkPuzzleCompletion() immediately sees the new currentPosition and can set isCompleted = true.

How to prevent it

  • Default to @Binding for mutable child state — if a child view needs to modify a value that the parent also reads, use @Binding.
  • Remember struct copy semantics — passing a struct to @State in a child always copies. The child’s @State is a new source of truth, not a window into the parent’s.
  • Use ForEach($collection) for bindings — when iterating over an @State array and you need child mutations to persist, iterate the projected value ($pieces) and capture the binding ($piece).
  • Keep @State private — @State should only be used for view-local UI state (e.g., isPresented, textFieldText). Shared model data belongs in @Observable/@StateObject classes or flows via bindings.

FAQ

Why does @State in the child create a copy instead of a reference?

@State allocates storage owned by the view instance. When you initialize PuzzlePieceView(piece: piece), the struct value is copied into that storage. The property wrapper does not create a reference to the original array element.

Can I use a class (@Observable) instead of a struct to avoid this?

Yes. If PuzzlePiece were an @Observable class, passing the same instance to multiple views would share mutations automatically. However, value types are preferred for simple data models; bindings give you reference-like behavior without reference semantics.

What if I’m on iOS 15 and can’t use ForEach($collection)?

Use pieces.indices and bind manually:

ForEach(pieces.indices, id: \.self) { index in
    PuzzlePieceView(
        piece: $pieces[index],
        // ...
    )
}

This achieves the same binding pass-through on older deployment targets.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.