Fetch data from Background Thread with SwiftData
Query
is a great macro to start a small project with swiftData, it seems to be magic to be fair, you can share the models thought the view hierarchy with Bindable
and save the modifications using the mainContext
. But there is a huge problem with it, it runs on MainThread
, if you don’t have to fetch large amounts of data this is not problem at all, but if this is your case you will be searching how you can make this fetches from Background thread.
Here it comes ModelActor
to save us, with ModelActor
we ensure all the transactions with an specific context are serialised, to initialise a model actor we have to provide a ModelContainer
which later will create a ModelContext
for us to fetch, save, and delete our models. This is great but there some things that we have to pay attention if we want to run as expected.
Imagine we have a simple app to track our expenses, of course we can filter per month, but also we want to be able to see how much spent in the whole time, sounds like a lot of data right? To fetch the data we will create an actor and fetch the data from an async function, in the example we will create a ViewModel with a load method and…. it is still executed on MainThread
even if the fetch is in its own actor and there is a suspension point the fetch is executed in MainThread
.
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@State private var viewModel = ContentViewModel()
var body: some View {
NavigationSplitView {
List {
ForEach(viewModel.expenses) { 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))
}
}
}
} detail: {
Text("Select an item")
}
.task {
await viewModel.load(modelContainer: modelContext.container)
}
}
}
#Preview {
ContentView()
.modelContainer(for: Expense.self, inMemory: true)
}
import Foundation
import SwiftData
@MainActor @Observable
class ContentViewModel {
var expenses: [Expense] = []
func load(modelContainer: ModelContainer) async {
let service = ItemActor(modelContainer: modelContainer)
expenses = (try? await service.fetchItems()) ?? []
}
}
import Foundation
import SwiftData
@ModelActor
actor ItemActor {
func fetchItems() throws -> [Expense] {
try modelContext.fetch(FetchDescriptor<Expense>())
}
}
I saw some articles on internet mentioning that creating the modelActor using Task.detached
solve the problem, but I think that should be our last resource since we loose the benefits of structured concurrency and cancellation from .task
modifier, so we are going to call the fetch from a nonisolated
method in the viewModel (This is necessary since the ViewModel entirely is marked as MainActor
if you add the macro to specific functions but not the one which is going to execute the fetch it would be the same).
import Foundation
import SwiftData
@MainActor @Observable
class ContentViewModel {
var expenses: [Expense] = []
func load(modelContainer: ModelContainer) async {
expenses = (try? await fetchData(modelContainer: modelContainer)) ?? []
}
nonisolated func fetchData(modelContainer: ModelContainer) async throws -> [Expense] {
let service = ItemActor(modelContainer: modelContainer)
return try await service.fetchItems()
}
}
As you can see in the image above, now the fetch is being executed in a background thread now.
One last comment, it doesn’t matter where the ModelActor
is created but from which context its functions are called. If you are executing an operation (fetch, insert, delete) from a function marked as MainActor
it will execute on it
Conclusion
Using ModelActor
with SwiftData provides a robust way to handle data operations off the MainThread
, ensuring better performance and responsiveness, especially when working with large datasets. However, it's crucial to carefully manage how and where these calls are executed to avoid inadvertently running them on the MainThread
.
Bonus Track
If you are interested to see how it works adding and removing elements while keeping the UI consistent and complying with Swift 6 concurrency checks don’t forget to check the github project https://github.com/sebasf8/SwiftDataTest