Fix: SwiftUI View Not Updating When Observable Class Property Changes

Fix: SwiftUI View Not Updating When Observable Class Property Changes

Written by

in

Quick answer: The view doesn’t update because the timer callback runs off the main thread, or the @State view model is recreated on each render. Move the timer logic into the @Observable class, mark it @MainActor, and pass the shared instance via @Bindable so mutations trigger a redraw.

Why this happens

The symptom — print statements fire but the view stays on screen — points to two common causes:

  1. Off-main-thread mutation. Timer callbacks execute on a background queue. SwiftUI only processes @Observable changes on the main actor, so the view never sees the new value.
  2. View-model recreation. @State var loadingViewModel = LoadingViewModel() creates a fresh instance every time LoadingView re-renders. The timer you started belongs to an instance that is immediately discarded, and the callback captures a stale reference.

The accepted Stack Overflow answer suggests moving data loading into the model’s init because onAppear runs after the first body evaluation. That advice is correct for initial data, but it doesn’t address the mutation problem you’re seeing.

How to fix it

1. Make the observable class drive the timer (recommended)

Keep all timing logic inside the @Observable class and annotate it with @MainActor. The view then only binds to the published property.

import SwiftUI
import Observation

@MainActor
@Observable
final class LoadingViewModel {
    var showLoadingView = true
    private var timer: Timer?
    private let goalTime: TimeInterval = 5
    private var elapsed: TimeInterval = 0

    func start() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            Task { @MainActor in
                self?.tick()
            }
        }
    }

    private func tick() {
        elapsed += 1
        if elapsed >= goalTime {
            timer?.invalidate()
            timer = nil
            showLoadingView = false
        }
    }

    deinit { timer?.invalidate() }
}

struct LoadingView: View {
    @Bindable var mainViewModel: MainViewModel
    @State private var loadingVM = LoadingViewModel()

    var body: some View {
        if loadingVM.showLoadingView {
            ProgressView()
                .onAppear { loadingVM.start() }
        }
    }
}

Key points:
– @MainActor guarantees every mutation hits the main thread.
– LoadingViewModel is a reference type owned by @State; it survives view re-renders.
– The timer captures self weakly and hops to the main actor for each tick.

2. Keep the external timer but marshal to the main actor

If you must keep the timer in a separate type, wrap the mutation in MainActor.run:

func timer(mainViewModel: MainViewModel) {
    // … your existing logic …
    if time >= goalTime, mainViewModel.gameIsReady {
        stopTimer()
        Task { @MainActor in
            mainViewModel.dismissLoading()
        }
    }
}

This works but scatters concurrency logic. Prefer fix #1.

3. Pass a shared instance instead of recreating

If LoadingViewModel must stay separate, instantiate it once in the parent and inject it:

struct ParentView: View {
    @State private var loadingVM = LoadingViewModel()
    @State private var mainVM = MainViewModel()

    var body: some View {
        LoadingView(mainViewModel: mainVM, loadingViewModel: loadingVM)
    }
}

struct LoadingView: View {
    @Bindable var mainViewModel: MainViewModel
    @Bindable var loadingViewModel: LoadingViewModel
    // …
}

Now the same LoadingViewModel instance persists across renders.

How to prevent it

  • Annotate every @Observable class that touches UI with @MainActor.
  • Never start long-running work in onAppear; use the model’s init or a dedicated start() method.
  • Own view models with @State (or @StateObject pre-iOS 17) in the parent view, then pass them down with @Bindable.
  • Use Timer.publish(every:on:in:).autoconnect() in Combine pipelines for a fully main-thread-safe alternative.

FAQ

Why does print show the new value but the view doesn’t change?

print executes immediately on whatever thread the callback runs. SwiftUI’s diffing engine only runs on the main actor, so the view hierarchy isn’t invalidated until the mutation is marshaled there.

Can I keep @State var loadingViewModel = LoadingViewModel() inside the child view?

Only if LoadingView is the sole owner and never re-created. In a navigation stack or list, the child view can be destroyed and recreated, losing the timer. Hoist the instance to a stable ancestor.

Is @Bindable required for @Observable classes?

@Bindable creates a two-way binding for a single property (e.g., $viewModel.showLoadingView). For read-only observation you can just use the property directly; SwiftUI tracks accesses automatically. Use @Bindable when you need a Binding for controls like Toggle or TextField.

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.