SwiftUI Toolbar appearance not updating when bound property changes

Summary

A developer reported that a SwiftUI toolbar button’s tint color failed to update when the underlying data model changed, despite other parts of the UI updating correctly. The core issue was a misunderstanding of SwiftUI’s data dependency tracking: computed properties are not automatically observed by SwiftUI, causing the view to miss updates when only the dependency of a computed property changed.

Root Cause

The Settings class exposes styling via a computed property theme, which derives its value from themeId:

@Model class Settings {
    var themeId: String = "Summer"
    var theme: Theme {
        Theme.AllThemes.first { $0.id == themeId } ?? Theme.AllThemes[0]
    }
}

In SettingsView, the toolbar button references settings.theme.buttonColor:

.toolbar {
    ToolbarItem(placement: .cancellationAction) {
        Button("Close") { ... }
            .tint(settings.theme.buttonColor)
    }
}

When themeId changes, SwiftUI triggers an update for themeId. However, SwiftUI does not know that theme depends on themeId. Because settings is wrapped in @Binding, SwiftUI monitors the reference identity of settings, not the internal properties of the class instance.

Consequently:

  • settings.theme.buttonColor is not marked as a dependency during the view’s render pass.
  • The view’s body is not re-evaluated for the toolbar.
  • The tint remains stale until an unrelated state change forces a broader refresh.

Why This Happens in Real Systems

  • Data abstraction leaks: Encapsulated logic (like a computed theme) often hides its dependencies from the observer (SwiftUI).
  • Reference semantics clash with value-based observation: @Binding tracks object identity, not property access patterns within the object.
  • Performance optimization: SwiftUI avoids expensive deep-diffing of class properties by default. It relies on explicit change signals (e.g., @Published or explicit value types).
  • Layered architecture: View logic often decouples from data logic, making it easy to overlook that an intermediate computed property breaks the reactivity chain.

Real-World Impact

  • Silent UI desynchronization: The UI appears functional but displays stale data, leading to user confusion.
  • Inconsistent state propagation: Only specific view elements update (e.g., the background view), while others (e.g., toolbars) remain static, breaking the “single source of truth” visual contract.
  • Increased debugging complexity: The issue is non-deterministic and depends on view rendering cycles, making it hard to reproduce reliably.
  • User experience degradation: Theming issues immediately signal “low quality” or “buggy” apps to users.

Example or Code (if necessary and relevant)

To fix this, you must ensure SwiftUI tracks the dependency. The most robust way is to explicitly reference the dependency or use a value type wrapper.

1. Fix by referencing the dependency directly (The “Brute Force” fix):

This works because you are now explicitly reading themeId, which SwiftUI observes via the @Binding.

.toolbar {
    ToolbarItem(placement: .cancellationAction) {
        Button("Close") { ... }
            // We read themeId to force SwiftUI to track it,
            // even if we don't use the value directly here.
            .tint(settings.themeId.isEmpty ? .blue : settings.theme.buttonColor)
    }
}

2. Fix by hoisting the value (The “Clean” fix):

Extract the specific value needed for the view before the view body renders. This forces the dependency resolution at the call site.

struct SettingsView: View {
    @Binding var isSettingsVisible: Bool
    @Binding var settings: Settings

    var body: some View {
        // Local binding ensures SwiftUI tracks the specific property
        let currentTheme = settings.theme

        NavigationStack {
            Form { ... }
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Close") { ... }
                        .tint(currentTheme.buttonColor)
                }
            }
        }
    }
}

How Senior Engineers Fix It

Senior engineers address this by bridging the gap between OOP encapsulation and FRP reactivity:

  1. Adopt Value Types for View State: Convert Settings from a @Model class to a struct for UI interactions, or use @Observable (iOS 17+) which handles observation more granularly than @StateObject/@Binding on classes.
  2. Explicit Dependency Signaling: If sticking to classes, implement the Observable protocol and use the @Published property wrapper not just on storage, but potentially on computed properties (though computed @Published is tricky; usually, you publish the source of truth).
  3. View-Layer Normalization: Never rely on complex computed properties inside view modifiers. Flatten the data at the view boundary. let color = settings.theme.buttonColor inside var body is a senior pattern because it makes the dependency explicit to the compiler.
  4. ViewModel Pattern: Use a dedicated ViewModel class that conforms to ObservableObject, explicitly exposing the flattened, reactive properties required by the view.

Why Juniors Miss It

Juniors often miss this because:

  • Mental Model Mismatch: They assume “if data changes, the view updates” universally, not realizing that SwiftUI only tracks values accessed during the body execution.
  • Focus on Logic, not Flow: They verify that the data is updating (the themeId change) but fail to check how the view consumes that update.
  • Black Box Abstraction: They treat computed properties as magic getters rather than functions that need to be explicitly called/dependent upon within the reactive graph.
  • Over-reliance on “Magic”: They expect @Binding to magically propagate changes to deep properties without the necessary @Published signaling or value-type conversion.