iosswiftswiftuiswiftdata

How do I refresh a SwiftData (manual) fetch whenever the database is changed?


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


Solution

  • modelContext without @Query

    To address one of my comments and an important aspect of SwiftData and modelContext that I actually wasn't aware of, it is important to note that to really benefit from most of its features, you have to use @Query.

    That includes the ability for views to react to operations like .insert() and .save(), but also for observation using .onChange(of:). So my suggestion in comments about using .onChange(of: modelContext.hasChanges) { oldValue, newValue in to trigger your .load function will not work if you don't use @Query.

    This is because without Query, modelContext is not observable in a way that will trigger views to update if it changes. You can use it as reference, but you'll need other mechanism to trigger view updates.

    The same applies to other features, like FetchDescriptor for example, for which the documentation states:

    If you’re displaying the fetched models in a SwiftUI view, use the descriptor with the Query(_:animation:) macro instead.

    ModelContext.didSave notifications

    One option for triggering a viewModel function when the context saves is registering for and listening to notifications posted by modelContext. This is very limiting, however, because only the .save() posts a notification, which constrains you to only knowing the state of data after it was saved and not before.

    This prevents you from performing incremental or optimistic updates to the UI, and leaves you with no other choice but to refetch the entire data, even if only one item was added or removed. Depending on the data set, this can lead to unnecessarily heavy fetch operations that will require careful management of the threads, actors and identity to reconcile data and view updates without blocking the UI or causing undesirable flickers.

    But since ModelContext.didSave is right now the only means available to know that a .save() occurred (together with the NotificationKey cases), you can use notifications(named:object:), which is a more modern way of working with notifications, rather than using AsyncStream or Combine's publisher.

    // Use the modern, async listener for the .didSave notification.
    let notifications = NotificationCenter.default.notifications(
        named: ModelContext.didSave
    )
    
    // This loop waits patiently and efficiently for notifications to arrive.
    // It will run for as long as this ViewModel exists.
    for await notification in notifications {
        
        // You could inspect the notification's userInfo here for details
        // about what was inserted, updated, or deleted.
        
        // Call your fetch function.
        await load()
    }
    

    One of the benefits of this style is automatic lifestyle management. Compared to using a Task<Void, Never>? that uses AsyncStream, and a cancel in deinit (a manual lifecycle management), the boilerplate (not to mention the mental model) is considerably reduced.

    What about a modelContext wrapper?

    If the limitation is that modelContext is not observable, you could implement an @Observable wrapper that will have the duty to manage model context state, allow for custom loading state management, listen for notifications like didSave and allow for optimistic updates (like @Query would) through its observable properties.

    This means you'd have to adopt using the wrapper-provided functions to perform updates. So instead of modelContext.insert(item), you'd use:

    contextWrapper.insert(item)
    

    And similarly for all other related functions like .save(), .delete(), etc.

    The good thing is that because it's @Observable, it would allow for optimistic UI updates, where adding or removing items can be immediately reflected in the view, without the need for a refetch of the entire data.

    But once you will inevitably go down the rabbit whole of making it more complete, to cover all logical cases and scenarios of model context operations, you'll realize that this exercise is basically reinventing the wheel. That's because everything the wrapper provides (maybe minus the custom state management, like loading, idle, etc.) already exists in the form of a @Query.

    So back to @Query then?

    If you don't want to exhaust yourself building a full wrapper, but enjoy the idea of optimistic UI updates, maybe it's worth giving @Query another shot, because it will probably do what you need with the right setup.

    Here's a fully working example that uses @Query (two, actually), initialized with a predicate provided by the viewModel for filtering, complete with a listener for didSave notifications and a simulated loading state indicator.

    import SwiftUI
    import SwiftData
    
    @Model
    final class Item {
        
        var timestamp: Date
        var isArchived: Bool = false
        var isSpecial: Bool = false
        var isFavorite: Bool = false
        
        init(timestamp: Date) {
            self.timestamp = timestamp
        }
    }
    
    @MainActor
    @Observable
    class ItemViewModel {
        
        // A nested struct to hold all filter states cleanly.
        struct Filters {
            var onlyArchived = false
            var onlySpecial  = false
            
            var description: String {
                var activeFilters: [String] = []
                
                if onlyArchived {
                    activeFilters.append("Archived")
                }
                
                if onlySpecial {
                    activeFilters.append("On Sale")
                }
                
                if activeFilters.isEmpty {
                    return "All Items"
                } else {
                    return activeFilters.joined(separator: " & ")
                }
            }
        }
        
        var isLoading = false
        var filters = Filters()
    
        var statusMessage: String?
        
        private var modelContext: ModelContext
    
        // The ViewModel is initialized with its required ModelContext dependency.
        init(modelContext: ModelContext) {
            self.modelContext = modelContext
        }
    
        // The View calls this method via .onChange to keep the ViewModel's
    
        /// A computed property that creates a live, compound predicate based on the filter struct.
        /// The View uses this to configure its @Query.
        var predicate: Predicate<Item> {
            let onlyArchived = filters.onlyArchived
            let onlySpecial = filters.onlySpecial
            
            return #Predicate<Item> { item in
                // An item must satisfy all active filters.
                // If a filter is 'false', the condition effectively becomes 'true' for that part.
                (onlyArchived ? item.isArchived == true : true) &&
                (onlySpecial ? item.isSpecial == true : true)
            }
        }
    
        func save() async {
            // A. Essential Check: Only proceed if there are actual changes to save.
            guard modelContext.hasChanges else {
                print("No changes to save.")
                self.statusMessage = "No Changes to Save"
                Task {
                    try? await Task.sleep(for: .seconds(1))
                    self.statusMessage = nil
                }
                return
            }
    
            // B. Turn the loading indicator ON.
            self.isLoading = true
            defer { self.isLoading = false }
            
            
            // --- Artificial Delay for UI Demonstration ---
            // This delay simulates a longer save operation (e.g., a network request).
            // It ensures the loading indicator is visible. Remove for production.
            try? await Task.sleep(for: .seconds(1))
            
            // C. Use a modern, concurrent listener for the .didSave notification.
            let notifications = NotificationCenter.default.notifications(
                named: ModelContext.didSave
            )
            
            do {
                try modelContext.save()
            } catch {
                print("Save failed: \(error)")
                return
            }
            
            // D. Wait patiently for the .didSave notification to arrive.
            // This confirms the save transaction is complete and other parts of the app,
            // like @Query, have been notified to update.
            for await _ in notifications {
                // As soon as the first notification arrives, we know the process is complete.
                break
            }
            
            //Reminder that loading state is reverted by the defer specified higher up, to ensure the view cannot remain in a stuck loading state
        }
        
        func toggleFavorite(_ item: Item) {
            item.isFavorite.toggle()
        }
    }
    
    
    struct FilteredDataView: View {
        @Environment(\.modelContext) private var modelContext
        
        // This view receives the single source of truth for state.
        @Bindable var viewModel: ItemViewModel
        
        // The @Query is configured using the ViewModel's live predicate (via init below)
        @Query private var filteredItems: [Item]
    
        // Optional - This second query fetches ALL items, just for the total count. It's efficient because no properties are accessed. Similar to SELECT COUNT(*) FROM Item;
        @Query private var allItems: [Item]
        
        init(viewModel: ItemViewModel) {
            self.viewModel = viewModel
            // Configure the @Query with the ViewModel's predicate
            // and sort by the timestamp (newest first).
            _filteredItems = Query(filter: viewModel.predicate, sort: \.timestamp, order: .reverse, animation: .default)
        }
    
        var body: some View {
            List {
                Section {
                    ForEach(filteredItems) { item in
                        RowItemView(item: item)
                            .swipeActions(edge: .trailing) {
                                //Delete
                                Button {
                                    withAnimation {
                                        modelContext.delete(item)
                                    }
                                } label: {
                                    Image(systemName: "trash")
                                }
                                .tint(.red)
                            }
                            .swipeActions(edge: .leading) {
                                //Favorite
                                Button {
                                    withAnimation {
                                        viewModel.toggleFavorite(item)
                                    }
                                } label: {
                                    Image(systemName: "star")
                                }
                                .tint(.teal)
                            }
                    }
                } header: {
                    Text(viewModel.filters.description)
                } footer: {
                    Text("Showing \(filteredItems.count) of \(allItems.count) items")
                        .font(.footnote)
                        .foregroundStyle(.secondary)
                        .frame(maxWidth: .infinity, alignment: .center)
                    
                }
            }
            .toolbar {
                
                //Add item button
                ToolbarItem(placement: .primaryAction) {
                    Button("Add Item") {
                        let newItem = Item(timestamp: Date())
                        newItem.isSpecial = Bool.random()
                        newItem.isArchived = Bool.random()
                        newItem.isFavorite = Bool.random()
                        
                        modelContext.insert(newItem)
                    }
                }
                
                //Loading indicator
                ToolbarItem(placement: .principal) {
                    Group {
                        if let message = viewModel.statusMessage {
                            Text(message)
                        }
                        
                        if viewModel.isLoading {
                            HStack {
                                Text("Loading")
                                ProgressView()
                            }
                        }
                    }
                    .font(.footnote)
                    .foregroundStyle(.secondary)
                    .transition(.opacity.animation(.easeInOut))
                    .padding(.top, 2)
                }
    
                //Save button
                ToolbarItem(placement: .topBarLeading) {
                    Button("Save") {
                        Task { await viewModel.save() }
                    }
                    .disabled(viewModel.isLoading)
                }
                
            }
        }
    }
    
    struct RowItemView: View {
        
        //Parameters
        let item: Item
        
        //Body
        var body: some View {
            LabeledContent {
                if item.isSpecial {
                    Image(systemName: "tag.fill")
                        .foregroundStyle(.orange)
                }
            } label: {
                Label {
                    Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
                } icon: {
                    if item.isFavorite {
                        Image(systemName: item.isFavorite ? "star.fill" : "circle.fill")
                            .imageScale(.medium)
                            .foregroundStyle(item.isFavorite ? .teal : .green)
                    }
                    else {
                        Image(systemName: "circle")
                            .hidden()
                    }
                }
                let statusText = item.isArchived ? "Archived" : "Active"
                let statusColor: Color = item.isArchived ? .indigo : .green
                let saleStatusText = item.isSpecial ? "On sale" : "Regular"
                let saleStatusColor: Color = item.isSpecial ? .orange : .gray
                
                HStack(spacing: 0) {
                    Text(statusText)
                        .foregroundStyle(statusColor)
                    Text(", ")
                    Text(saleStatusText)
                        .foregroundStyle(saleStatusColor)
                }
            }
        }
    }
    
    struct FiltersView: View {
        @Bindable var viewModel: ItemViewModel
        
        var body: some View {
            HStack {
                Picker("Status", selection: $viewModel.filters.onlyArchived) {
                    Text("All").tag(false)
                    Text("Archived").tag(true)
                }
                .pickerStyle(.segmented)
                
                Picker("Special", selection: $viewModel.filters.onlySpecial) {
                    Text("All").tag(false)
                    Text("On Sale").tag(true)
                }
                .pickerStyle(.segmented)
            }
            .padding([.horizontal, .top])
        }
    }
    
    
    struct ContentView: View {
    
        @State private var viewModel: ItemViewModel
    
        init(modelContext: ModelContext) {
            _viewModel = State(initialValue: ItemViewModel(modelContext: modelContext))
        }
    
        var body: some View {
            NavigationStack {
                VStack {
                    // The FiltersView directly modifies the ViewModel.
                    FiltersView(viewModel: viewModel)
                    
                    FilteredDataView(viewModel: viewModel)
                }
                .navigationTitle("Items")
            }
        }
    }
    
    #Preview {
        let container = try! ModelContainer(for: Item.self, configurations: .init(isStoredInMemoryOnly: true))
        let context = container.mainContext
        
        // Preview seed data
        for _ in 0..<5 {
            let newItem = Item(timestamp: Date())
            newItem.isSpecial = Bool.random()
            newItem.isArchived = Bool.random()
            context.insert(newItem)
        }
        try? context.save()   // <- saving seeded items so they're available when Preview loads
        
        return ContentView(modelContext: context)
            .modelContainer(container)
    }
    

    enter image description here