Summary
I have a View Model class, paired with a SwiftUI View, that fetches filtered entries from SwiftData using a ModelActor. I've used SwiftUI's .task(id:) to trigger re-loads in the View Model whenever my filter predicate changes, but I want to also trigger re-loads whenever the database itself is updated throughout my UI. How can I tell my View Model to re-load whenever SwiftData commits my insert() and delete() calls?
Context and Example Code
I'm fetching a large number of items from a SwiftData store using a frequently-changing predicate. Traditional @Query setups did not provide the flexibility I wanted (specifically for rendering loading states), so I created a background actor to handle fetching the data:
import Foundation
import SwiftData
@Model
final class Item {
var timestamp: Date
init(timestamp: Date) {
self.timestamp = timestamp
}
}
struct ItemView: Identifiable, Sendable {
var id: PersistentIdentifier
var timestamp: Date
init(_ model: Item) {
id = model.id
timestamp = model.timestamp
}
}
@ModelActor
actor ThreadsafeBackgroundActor: Sendable {
private var context: ModelContext { modelExecutor.modelContext }
func fetchData(_ predicate: Predicate<Item>? = nil) throws -> [ItemView] {
let descriptor = if let p = predicate {
FetchDescriptor<Item>(predicate: p)
} else {
FetchDescriptor<Item>()
}
let items = try context.fetch(descriptor)
return items.map(ItemView.init)
}
}
I've also got a view model calling the actor:
import SwiftUI
import SwiftData
@Observable
class ItemListViewModel {
enum State {
case idle
case loading
case failed(Error)
case loaded([ItemView])
}
private(set) var state = State.idle
func fetchData(container: ModelContainer, predicate: Predicate<Item>) async throws -> [ItemView] {
let service = ThreadsafeBackgroundActor(modelContainer: container)
return try await service.fetchData(predicate)
}
@MainActor func load(container: ModelContainer, predicate: Predicate<Item>) async {
state = .loading
do {
// Artificial delay to visualize loading state
try await Task.sleep(for: .seconds(1))
let items = try await fetchData(container: container, predicate: predicate)
state = .loaded(items)
} catch is CancellationError {
state = .idle
} catch {
state = .failed(error)
}
}
}
And I've got a task on my SwiftUI view to kick off the initial load:
import SwiftUI
import SwiftData
struct ContentView: View {
@State private var viewModel = ItemListViewModel()
@Environment(\.modelContext) private var modelContext
@State private var refreshCount = 0
var body: some View {
NavigationSplitView {
Group {
switch viewModel.state {
case .idle:
EmptyView()
case .loading:
ProgressView()
case .failed(let error):
Text("Error: \(error)")
case .loaded(let items):
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
} label: {
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
}
}
}
}
}
#if os(macOS)
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
#endif
.toolbar {
#if os(iOS)
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
#endif
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
ToolbarItem {
Button {
do {
try modelContext.save()
} catch {
fatalError(error.localizedDescription)
}
} label: {
Label("Save", systemImage: "square.and.arrow.down")
}
}
ToolbarItem {
Button {
refreshCount += 1
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
}
}
} detail: {
Text("Select an item")
}
.task(id: refreshCount) {
// Typically the task ID would be tied to a dynamic predicate.
// I have it tied to a UI button (and made the predicate static) for a minimal example.
await viewModel.load(container: modelContext.container, predicate: #Predicate { _ in true })
}
}
private func addItem() {
withAnimation {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
}
}
}
This setup works excellently until actions in the view update anything in the database. ModelContext actions, e.g. context.insert() in addItem, do not trigger my load function. Calling context.save() (in the save button's action) does not force an update, either.
How can I tell the load function to re-run whenever SwiftData commits my insert() and delete() calls throughout the codebase? Preferably, I'd like to minimize the additional code I'm putting in each location I call ModelContext functions. Letting SwiftData operate with minimal intervention from me is the goal.
Attempts and Research
I've considered manual (user-initiated) refresh triggers, like
List's.refreshableutility, or the "refresh" button in the example above. While useful for users who know they need the data updated, I don't want the validity of the presented data to be dependent on the user's intervention.I've attempted a brute-force route of introducing a
refreshCountstate variable, passed around wherever needed. Areas of the code that update the database incrementrefreshCount, and a separate task in my list view watches the count:.task(id: predicate) { /* Same call */ } .task(id: refreshCount) { // Eww... viewModel.load(container: modelContext.container, predicate: predicate) }Not only is this strategy tedious and brittle, but it also runs the risk of calling
loadmultiple times unnecessarily (especially at the initial render time).I've looked into Swift's streaming notification system. I'm fairly confident that
NSPersistentStoreRemoteChangeis what I want to watch. I just cannot figure out how/where to initialize that watcher.addObserverasks for Objective-C annotations. I don't think that.publisher().sink { }is the solution either, because I want to kick off the mutating callviewModel.load()in the (escaping) closure.

var hasChanged: Bool = falsein your@Observable class CardListViewModel, and whenever you do your database updates (inserts, modifications, deletes), toggle it. Then add a.onChange(of: viewModel.hasChanged)in your code to action aviewModel.load(...).