Fix: @State Change Not Triggering View Re-render in SwiftUI

Fix: @State Change Not Triggering View Re-render in SwiftUI

Written by

in

Quick answer: SwiftUI only re-renders a view when it directly reads a @State property. Passing @State as a Binding to a child view lets the child update without re-evaluating the parent’s body. This is dependency tracking, not ownership.

Why this happens

SwiftUI’s rendering model confuses many developers because it tracks dependencies, not ownership. A view only becomes dependent on a @State value when its body directly reads that value. Simply declaring @State in a view does not guarantee the view will re-render when that state changes.

In the example from the question, ParentView declares @State var counter but never reads counter inside its body. The value is passed as a Binding ($counter) to ChildView inside a .sheet. Because ParentView‘s body doesn’t access counter, SwiftUI has no reason to re-evaluate ParentView.body when counter changes. The child view, however, reads the binding directly, so it updates independently.

This behavior is intentional. SwiftUI builds a dependency graph at render time. If a view’s body doesn’t touch a piece of state, that state is not a dependency, and changes to it are not scheduled to invalidate the view. The same principle applies when passing state to view modifiers like .sheet(isPresented:) — the modifier manages its own dependency on the binding.

@Published behaves differently because it lives on an ObservableObject (a reference type). SwiftUI subscribes to the object’s objectWillChange publisher. Any view that holds an @ObservedObject or @StateObject reference to that object will re-render when any @Published property changes, unless you use @ObservedObject with a custom objectWillChange or move to the observation framework in iOS 17+.

How to fix it

1. Read the state in the parent body if you need the parent to update

If ParentView should react to counter changes, read the value somewhere in body. Even a harmless read like _ = counter or using it in a Text forces the dependency.

struct ParentView: View {
    @State private var counter = 0

    var body: some View {
        let _ = Self._printChanges()
        VStack {
            // Reading counter here creates the dependency
            Text("Parent sees: \(counter)")
                .font(.caption)
                .foregroundStyle(.secondary)
            Button("Increment from Parent") { counter += 1 }
            Button("Open Sheet") { showSheet = true }
        }
        .sheet(isPresented: $showSheet) {
            ChildView(counter: $counter)
        }
    }
}

Now ParentView.body re-evaluates when counter changes.

2. Use the Observation framework (iOS 17+) for fine-grained updates

If you want a parent to update only when specific properties change, migrate from ObservableObject/@Published to @Observable:

@Observable
final class CounterModel {
    var value = 0
}

struct ParentView: View {
    @State private var model = CounterModel()

    var body: some View {
        // Only re-renders when model.value is read and changes
        Text("Value: \(model.value)")
        ChildView(model: model)
    }
}

struct ChildView: View {
    @Bindable var model: CounterModel
    var body: some View {
        Button("Increment") { model.value += 1 }
    }
}

With @Observable, SwiftUI tracks exactly which properties each view reads.

3. Understand that Binding passes write access without read dependency

Passing $state to a child gives the child a Binding<Value>. The child reads and writes through that binding. The parent only re-renders if it reads the state. This is not a bug — it’s how SwiftUI avoids unnecessary work.

If you need the parent to know about a change initiated by the child, you have two options:
– Read the state in the parent (fix 1)
– Use a callback/closure from the child to notify the parent explicitly

struct ChildView: View {
    @Binding var counter: Int
    var onChange: (Int) -> Void = { _ in }

    var body: some View {
        Button("Increment") {
            counter += 1
            onChange(counter)
        }
    }
}

// In ParentView:
ChildView(counter: $counter) { newValue in
    // Parent can react here without reading counter in body
    print("Child changed counter to \(newValue)")
}

How to prevent it

  • Read every @State you care about in the view’s body. Even a let _ = state forces the dependency.
  • Use @Observable (iOS 17+) for reference-type models to get automatic fine-grained tracking.
  • Avoid @Published on ObservableObject when you need property-level granularity; it invalidates all observing views on any change.
  • Use Self._printChanges() during development to see exactly which views re-render and why.
  • Prefer value types (struct) with @State for view-local state; they follow the read-dependency rule predictably.

FAQ

Why does my parent view not update when a child changes a @State passed as Binding?

Because the parent’s body never reads that @State property. SwiftUI only schedules a re-render for views that directly depend on the value. The child updates because it reads the binding.

Is this different from @Published on ObservableObject?

Yes. @Published triggers objectWillChange on the whole object. Any view holding @ObservedObject or @StateObject re-renders when any @Published property changes, regardless of which property the view actually uses.

How can I make a parent react to a child’s binding change without reading the state in body?

Pass a callback closure from the parent to the child. The child calls the closure after mutating the binding. The parent can then perform side effects or update other state without needing to read the bound value in its body.

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.