Hi everyone,
I'm looking for the correct architectural guidance for my SwiftData implementation.
In my Swift project, I have dedicated async functions for adding, editing, and deleting each of my four models. I created these functions specifically to run certain logic whenever these operations occur. Since these functions are asynchronous, I call them from the UI (e.g., from a button press) by wrapping them in a Task.
I've gone through three different approaches and am now stuck.
Approach 1: @MainActor Functions
Initially, my functions were marked with @MainActor and worked on the main ModelContext. This worked perfectly until I added support for App Intents and Widgets, which caused the app to crash with data race errors.
Approach 2: Passing ModelContext as a Parameter
To solve the crashes, I decided to have each function receive a ModelContext as a parameter. My SwiftUI views passed the main context (which they get from @Environment(\.modelContext)), while the App Intents and Widgets created and passed in their own private context. However, this approach still caused the app to crash sometimes due to data race errors, especially during actions triggered from the main UI.
Approach 3: Creating a New Context in Each Function
I moved to a third approach where each function creates its own ModelContext to work on. This has successfully stopped all crashes. However, now the UI actions don't always react or update. For example, when an object is added, deleted, or edited, the change isn't reflected in the UI. I suspect this is because the main context (driving the UI) hasn't been updated yet, or because the async function hasn't finished its work.
My Question
I'm not sure what to do or what the correct logic should be. How should I structure my data operations to support the main UI, Widgets, and App Intents without causing crashes or UI update failures?
Here is the relevant code using my third (and current) approach. I've shortened the helper functions for brevity.
// MARK: - SwiftData Operations
extension DatabaseManager {
/// Creates a new assignment and saves it to the database.
public func createAssignment(
name: String, deadline: Date, notes: AttributedString,
forCourseID courseID: UUID, /*...other params...*/
) async throws -> AssignmentModel {
do {
let context = ModelContext(container)
guard let course = findCourse(byID: courseID, in: context) else {
throw DatabaseManagerError.itemNotFound
}
let newAssignment = AssignmentModel(
name: name, deadline: deadline, notes: notes, course: course, /*...other properties...*/
)
context.insert(newAssignment)
try context.save()
// Schedule notifications and add to calendar
_ = try? await scheduleReminder(for: newAssignment)
newAssignment.calendarEventIDs = await CalendarManager.shared.addEventToCalendar(for: newAssignment)
try context.save()
await MainActor.run {
WidgetCenter.shared.reloadTimelines(ofKind: "AppWidget")
}
return newAssignment
} catch {
throw DatabaseManagerError.saveFailed
}
}
/// Finds a specific course by its ID in a given context.
public func findCourse(byID id: UUID, in context: ModelContext) -> CourseModel? {
let predicate = #Predicate<CourseModel> { $0.id == id }
let fetchDescriptor = FetchDescriptor<CourseModel>(predicate: predicate)
return try? context.fetch(fetchDescriptor).first
}
}
// MARK: - Helper Functions (Implementations omitted for brevity)
/// Schedules a local user notification for an event.
func scheduleReminder(for assignment: AssignmentModel) async throws -> String {
// ... Full implementation to create and schedule a UNNotificationRequest
return UUID().uuidString
}
/// Creates a new event in the user's selected calendars.
extension CalendarManager {
func addEventToCalendar(for assignment: AssignmentModel) async -> [String] {
// ... Full implementation to create and save an EKEvent
return [UUID().uuidString]
}
}
Thank you for your help.
When using SwiftData, I typically consider the following pattern:
-
Start with accessing the data store from the main actor (
MainActor) and with mainContext. -
Use
@Queryto gather data that will be rendered in a SwiftUI view. That way, the query controller under the hood observes changes on the data store, and updates the view, as discussed in this post. -
When you have a heavy task that should be done in a background queue, create a ModelActor with your app's shared model container, and do the task with the isolated modelContext.
-
Use a Sendable data type to exchange data between the model actor (step 3) and the main actor. A SwiftData model is not
Sendable, but the model's persistentModelID is, and so can be passed across actors.
With this pattern, light tasks run in the main actor; heavy tasks run in a model actor and use Sendable types to exchange data across actors; @Query observes the changes on the data store and updates the UI. No race condition will happen.
The only issue is that you may hit a conflict when the main actor and a model actor access the data store simultaneously. If this can happen in your case, you might consider avoiding that carefully, as discussed in this post.
Now to your case:
In my Swift project, I have dedicated async functions for adding, editing, and deleting each of my four models. I created these functions specifically to run certain logic whenever these operations occur. Since these functions are asynchronous, I call them from the UI (e.g., from a button press) by wrapping them in a Task.
Assuming that your async functions are main actor isolated, which is true if they are functions of SwiftUI views, and you use mainContext to access the store, you would be fine. Otherwise, you will need to examine if the functions run in the main actor.
Approach 1: @MainActor Functions ... Approach 2: Passing ModelContext as a Parameter ...
I'll be interested in taking a look if you have a reproducible case.
An app and its widgets are different processes; an app and its App Intents can be in a same or different process. If your app, widget, and App Intents can manipulate the same data store, you might hit a conflict, as mentioned in the post.
Approach 3: Creating a New Context in Each Function ...
This's worth taking a look as well. In case you are using @Query, the SwiftUI view should update. In your widgets or App Intents where you can't use @Query, you might need to use FetchDescriptor to fetch data when you need.
Best,
——
Ziqiao Chen
Worldwide Developer Relations.