Correct SwiftData Concurrency Logic for UI and Extensions

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.

Answered by DTS Engineer in 863858022

When using SwiftData, I typically consider the following pattern:

  1. Start with accessing the data store from the main actor (MainActor) and with mainContext.

  2. Use @Query to 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.

  3. 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.

  4. 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.

Accepted Answer

When using SwiftData, I typically consider the following pattern:

  1. Start with accessing the data store from the main actor (MainActor) and with mainContext.

  2. Use @Query to 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.

  3. 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.

  4. 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.

Hi Ziqiao,

Thank you so much for the detailed response. Your guidance was extremely helpful.

I've refactored my DatabaseManager to be a ModelActor (using the shared container) and updated its functions to work with PersistentIdentifier as you suggested. This has completely resolved the data race crashes I was experiencing. Thank you!

I still have two small follow-up questions regarding the integration with App Intents and Widgets.

1. App Intents & EntityPropertyQuery with UUIDs

For my App Intents, I have entities like AssignmentEntity (conforming to AppEntity) and an AssignmentQuery (conforming to EntityPropertyQuery, EnumerableEntityQuery, etc.).

The protocol requires me to implement a function with this signature: func entities(for identifiers: [UUID]) async throws -> [AssignmentEntity]

This creates a conflict, as my DatabaseManager actor now operates on PersistentIdentifiers, not UUIDs.

My current workaround inside this required function is to create a new, temporary ModelContext just to fetch the models by their UUIDs. It looks like this:

// Inside the required func entities(for identifiers: [UUID])...
func entities(for identifiers: [UUID]) async throws -> [AssignmentEntity] {
    let context = ModelContext(DataModelEnum.container) // My shared container

    let f = FetchDescriptor<AssignmentModel>(predicate: #Predicate {
        identifiers.contains($0.id) // $0.id is my UUID property
    })
        
    let models = try context.fetch(f)
    return models.map { $0.toAssignmentEntity() } // Converting Model to Entity
}

My question is: Is this pattern—creating a new, temporary ModelContext inside each of these entities(for:) functions (and presumably inside all the other required query/data-import functions for all of my entity types)—the correct way to bridge the gap between the UUID requirement from AppEntity and my PersistentIdentifier-based actor?

2. Interactive Widgets & PersistentIdentifier as a String

My second question is about interactive widgets. I have a Button that triggers an intent, and the intent parameter must be a simple type like String.

Previously, I did this: Button(intent: WidgetIntent1(eventIdString: event.id.uuidString), ...)

Now that my DatabaseManager actor expects a PersistentIdentifier, I want to avoid passing the UUID string and then performing an extra fetch inside the intent just to get the PersistentIdentifier, only to then call the actor with that ID.

My question is: Is there a recommended way to convert a PersistentIdentifier into a String (to pass to the intent) and then reliably convert that String back into a PersistentIdentifier inside the intent's perform() method?

The alternative seems to be passing the UUID string, performing a fetch inside the intent just to get the model's PersistentIdentifier, and then passing that ID to my DatabaseManager actor (which then performs its own fetch using that ID). This "double fetch" feels very inefficient, and I'm wondering if there's a more direct approach.


Thanks again for all your help!

Michael

My question is: Is this pattern—creating a new, temporary ModelContext inside each of these entities(for:) functions (and presumably inside all the other required query/data-import functions for all of my entity types)—the correct way to bridge the gap between the UUID requirement from AppEntity and my PersistentIdentifier-based actor?

Yes. ModelContext.init(_ container: ModelContainer) returns a nonisolated context. In case you don't use the context and its associated model instances outside of the current actor, you will be fine.

My question is: Is there a recommended way to convert a PersistentIdentifier into a String (to pass to the intent) and then reliably convert that String back into a PersistentIdentifier inside the intent's perform() method?

There isn't. In Core Data, NSManagedObjectID provides uriRepresentation(), which returns a URL that can be converted to a string and vice versa. PersistentIdentifier instead exposes an id (ID), which is opaque.

The alternative seems to be passing the UUID string, performing a fetch inside the intent just to get the model's PersistentIdentifier, and then passing that ID to my DatabaseManager actor (which then performs its own fetch using that ID). This "double fetch" feels very inefficient, and I'm wondering if there's a more direct approach.

The alternative is actually fine because:

a. Unless the result set has a lot of data, the "double fetch" won't have a big impact.

b. Even with PersistentIdentifier, you will likely convert the identifier to a model in tha model actor by calling model(for:), which can be a fetch as well.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Hi Ziqiao,

Your previous guidance to use a ModelActor and PersistentIdentifiers has successfully resolved all my data race crashes.

However, I'm left with one persistent UI-level race condition, specifically related to deletion.

My View Structure

My setup is a standard master-detail pattern, which on iPhone (where I'm seeing this bug) acts like a NavigationStack:

  1. ScheduleView (Master): Uses @Query to fetch and display all events in a List. Each row is a NavigationLink. This view also has a swipe-to-delete action on its rows.
  2. EventDetailsView (Detail): The destination of the NavigationLink. It contains a "Delete" button.

The Problem: A dismiss() vs. @Query Race

When I tap the "Delete" button in EventDetailsView, I call my actor (e.g., await databaseManager.deleteEvent(id: event.persistentModelID)) and then immediately call dismiss() to pop the view.

The Bug: When the app returns to ScheduleView, the row for the just-deleted event is still visibly active in the list for at least 5 seconds before it finally animates out. This is a very significant delay, not just a flicker.

The Key Clue: I also have a swipe-to-delete action directly on the ScheduleView list. When I use that action, which calls the exact same databaseManager.deleteEvent(id:) function, the row disappears instantaneously as expected.

This strongly suggests the race condition is not with the ModelActor or @Query's observation itself. The problem is introduced specifically by the dismiss() action from the detail view.

What I've Tried That Failed

I have tried several approaches to solve this, but the race condition persists.

Attempt 1: Forcing @MainActor My first attempt was to wrap the entire operation in a Task marked with @MainActor inside EventDetailsView:

Task { @MainActor in
  await databaseManager.deleteEvent(id: event.persistentModelID)
  dismiss()
}

This did not fix the problem. The race condition remained, and the 5-second delay was still present upon returning to ScheduleView.

Attempt 2: "State Down, Actions Up" Pattern Next, I refactored my code to pass an onDelete: () async -> Void closure from ScheduleView into EventDetailsView. The body of this closure, which lives in ScheduleView, is: await databaseManager.deleteEvent(id: event.persistentModelID).

EventDetailsView now just calls await onDelete() and then dismiss().

This also did not fix the problem. Even though the await on the actor call finished within ScheduleView (the view that owns the @Query), the dismiss() in the child view still won the race, and the UI returned to ScheduleView before @Query could update the list.

I am now stuck. All my attempts to serialize these operations have failed, and the UI remains inconsistent for a long period.

What is the correct architectural pattern to solve this specific dismiss() vs. @Query race condition? How can I ensure ScheduleView's data is consistent before it reappears after the dismiss()?

Thanks, Michael

Assuming the task (Task) is triggered from a SwiftUI button action, adding @MainActor won't change anything because SwiftUI View is now MainActor-ed. With or without specifying @MainActor, the task will start from the main actor, do deleteEvent in the database manager actor, and come back to the main actor to run dismiss.

Regarding the delay, do you have a minimal project that reproduces the issue? If yes, I'd be interested in taking a look. I am wondering if deleteEvent calls other async methods, which eventually leads to dismiss being called before the deletion is done. Without looking into a reproducible case, however, I can't say anything for sure.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Correct SwiftData Concurrency Logic for UI and Extensions
 
 
Q