@MainActor conflict with async UseCase in SwiftUI ViewModel

I’m getting a data-race warning in my ProductViewModel :

"Sending main actor-isolated 'self.getProductsUseCase' to nonisolated callee risks causing data races"

@MainActor  
class ProductViewModel: ObservableObject {  
    @Published private(set) var products: [Product] = []  
    private let getProductsUseCase: GetProductsUseCase  

    func loadProducts() async {  
        do {  
            // ⚠️ Warning occurs here  
            products = try await getProductsUseCase.execute()  
        } catch { /* ... */ }  
    }  
}  

// UseCase & Repo:  
public struct GetProductsUseCase {  
    private let repository: ProductRepository  
    public func execute() async throws -> [Product] {  
        try await repository.getAll()  
    }  
}  

public protocol ProductRepository {  
    func getAll() async throws -> [Product]  
}  

What I’ve tried:

  1. Using Task.detached + MainActor.run → Still get warnings.
  2. Confirmed Product is a Sendable struct.

Question:
How can I safely call getProductsUseCase.execute() (which may run on a background thread) and update @Published state on @MainActor without triggering warnings?

Is there a Swift-recommended pattern for this?

Have look at How to subscribe to an actor’s publisher?

Where I wrote:

HTH,

GreatOm

Your protocol ProductRepository in your VM is not confirm with the sendable protocol.

protocol ProductRepository:Sendable {
    func getAll() async throws -> [Product]
}

Okay, I'll try to break this down a bit to hopefully make it more understandable ("try" because I feel not competent enough to promise I don't make a mistake in my reasoning... sorry in advance if I mess up!).

The getProductsUseCase is @MainActor isolated as it's part of the ProductViewModel. It's type GetProductsUseCase is not isolated to any actor, though, so the async execute function on it is also not isolated. Since SE-0461 is not yet fully released (leaving SE-0338 still in effect) that means execute runs on a generic executor, i.e. not the main actor.
So you have a context switch there, and since every member function gets its instance in the implicit self parameter, the getProductsUseCase must be passed to that other context, off the main actor. This explains the warning, it translates "Your getProductsUseCase instance is passed to this other context". "Callee" is the execute function and as written that runs not on the main actor, yet the property "lives" there.
Why is that a problem? Can it not be sent to that other context? No, because it is not Sendable. Why not? It's a struct without mutable state, no? Well, not exactly, as @Prashant (welcome, by the way!) points out, ProductRepository is not Sendable and you're using this as an existential there (I'd recommend to write that as `private let repository: any ProductRepository, btw).

This can be a solution, but you may also adapt in a different ways. I assume you don't have that as your concrete types that adopt that protocol are difficult to make Sendable? It's hard to give a recommendation for this here as I don't know how your other code looks. You might want to read up on the proposals I linked and investigate how isolated and #isolation work to ensure async functions run on specific actors. As long as Product itself is sendable, there are ways to get an array of it in one context and then pass to to the main actor, but as said, how exactly that can best be done in your case is hard to judge from here.

2 Likes

@Gero
Thanks a bunch for the detailed explanation! I’m really learning the ropes of actors and concurrency.

I never thought in this perspective :clap:


So, there are two types of actors: main actors and global actors. What’s the best way to call a class or function that performs an asynchronous operation from a main actor (which is often the view model)? For instance, loading data from an API or a large local JSON file?

Should we use nonisolated or detached task, or we can don't require

Sorry if this sounds like a basic question, but I’m trying to understand this stuff.

Thank y'all
I changed my usecase from this:
public struct GetProductsUseCase {}
or this:
public struct GetProductsUseCase: Sendable {}
to this:
public class GetProductsUseCase: @unchecked Sendable {}
and it fixed.

You're very welcome, I'm glad I could help your understanding a bit!

No, not quite. First, there are actors, the (relatively) new reference type that isolates state that will be shared among different execution contexts. Next, you can make actors adopt the GlobalActor protocol and annotate them with the @globalActor attribute, like so:

@globalActor
final actor MyGlobalActor: GlobalActor {
    static let shared = MyGlobalActor()
}

This defines a new attribute that you can use to annotate other, non-actor types, like so:

@MyGlobalActor // Annotating the entire type is the same as annotations each individual function/property
final class MyClass {
    func foo() { }  // You could also only annotate individual functions or properties in the class.
}

This in effect makes the annotated types (or the relevant annotated functions/properties) isolated on the global actor. That means, in effect unless you access them from a place that is already in that actors isolation (i.e. another function annotated with it or a function defined on the actor itself), you have to await them (even if they're not async!).

Now, the @MainActor is one (read: 1) such global actor the system already provides you with. It is a little special in that it also uses a custom serial executor that ensures to isolate everything to specifically the main thread. You could theoretically implement such a thing yourself, but there's no need for that, usually.

The usefulness of the main actor is to ensure that code that must run on the main thread (usually the underlying rendering logic for a UI system like UIKit or SwiftUI) does exactly that. This is why the relevant types are usually annotated with @MainActor, like @imany's ProductViewModel. Note that this does not violates @GreatOm's excellent article (in fact it does follow their suggested pattern).
In way, such types become "almost" actors, as in they share the annotated actor's isolation domain, but they, of course, stay their own type.


To your question:

This is difficult to generalize, I think.
First off, there's another question "hidden" in that: Besides thinking about being on the main actor or not, you need to also consider whether you're in an asynchronous context or in synchronous code.
If it's the latter, you basically have to start an unstructured Task. And in 9 out of 10 scenarios you don't have to start a detached one, btw. SwiftUI already offers a view modifier for this (.task and .task(id:)).
That Task will inherit the isolation context. So if your calling context (i.e. the function where you start the Task) is on the main actor, the new unstructured Task will inherit that. Inside that task you can then use the structured concurrency mechanisms (async let, task groups, generally call await things). You may think "But I need to do some stuff off the main actor!" and I will get to that in a second. The important part is that some of the asynchronous methods you call may, in fact, switch the isolation context (they are isolated to another actor). This is what you will want to design, ultimately.
One way to do so is, in fact, using an actor (as in your own defined actor type, like actor MyActor { ... }). Async methods defined on that will run in that actor's isolation context.
The problem arises when you use async methods that are not defined to run on a specific actor (those are nonisolated functions). The exec and getAll methods from the initial code example are like this.
As I explained those (implicitly) get their instance passed as a parameter so they can refer to self in their body. But if that instance was originally created in a different isolation context (like in the example) and its type is not Sendable, that's problematic.

Actors or types annotated with a global actor are (implicitly) sendable, so that is one option to get this done. A different approach can be to eliminate the need to pass the instance to the non-isolated function, for example by using a static method. That's often trivial if the function doesn't actually need any state data from the related type at all, or only a subset of sendable data I can pass individually as regular parameters.
There's various ways to approach this, but I don't feel comfortable to say there is "one best practice". In my mind it depends too much on what you're doing and what the used types (which may come from a library not under your control) do.

One word of warning though: Using Task (whether a detached or one with an explicitly global actor annotated closure parameter) to "escape" one isolation context doesn't help you against the problem of sending an instance. In the example above that is basically the reason why simply wrapping the call to execute in a Task.detached does not work: You still have to access the getProductsUseCase, i.e. pass that to the new task's isolation context, and that is the same problem as passing it to execute.

2 Likes

Sorry, my last post overlapped yours.

Using @unchecked Sendable is potentially dangerous. It silences the compiler, yes, but if your repository has mutable state you introduce a possible data race.
If you can, you should make ProductRepository inherit from Sendable and ensure your conforming types are indeed Sendable (have no mutable shared state or use other isolation mechanics, like Mutex).
@unchecked Sendable basically just means "Don't worry, compiler, the object is actually sendable, you just don't understand how". It implies you take care of ensuring it is indeed sendable in your own way, but if you then don't, you basically trick yourself.

1 Like

So, if you're going to write an API in Swift, do you have to make everything from the datasource and repository to the ViewModel conform to Sendable?

No, not necessarily. But if you create an instance in one context, but then want to use that instance in another context, then yes, its type must be Sendable.

I know that a common impression of that statement is "So I just have to make everything sendable?" But I would disagree with that. I have found that often times, I create and store instances "too far up" or that I pass stuff "down" that I don't actually need.

A quick, convoluted example:

private final class Language {
    var name: String

    init(name: String = "English") {
        self.name = name
    }

    func translate(_ input: String) async -> String {
        return "\(name) translation of \(input): ..."
    }
}

@MainActor
final class MyMessageTranslator {
    private let targetLang = Language()
    private(set) var translatedOutput = ""

    func setTargetLanguage(name: String) {
        targetLang.name = name
    }

    func loadAndTranslate() async throws {
        let (data, _) = try await URLSession.shared.data(from: URL(string: "https://something.com")!)
        let sourceString = String(data: data, encoding: .utf8)!
        translatedOutput = await targetLang.translate(sourceString)
    }
}

This is, probably, a common traditional pattern (simplified): You have a kind of "configurable" helper instance in your main actor bound type that does a job. Here that is:

  1. Translating a string asynchronously, off the main thread [1].
  2. Storing the configuration for that operation: the target language.

However, do I really need to couple these two things? Compare it to this:

private enum Language { // just for readability an enum
    static func translate(_ input: String, targetName name: String) async -> String {
        return "\(name) translation of \(input): ..."
    }
}

@MainActor
final class MyMessageTranslator {
    private(set) var targetLangName = "English"
    private(set) var translatedOutput = ""

    func setTargetLanguage(name: String) {
        targetLangName = name
    }

    func loadAndTranslate() async throws {
        let (data, _) = try await URLSession.shared.data(from: URL(string: "https://something.com")!)
        let sourceString = String(data: data, encoding: .utf8)!
        translatedOutput = await Language.translate(sourceString, targetName: targetLangName)
    }
}

I decoupled the actual logic that I need to run asynchronously from the place I store whatever I need to pass to that as data to work. Since the state I now pass is only Sendable types (in this case a single String, but more complex types can also be envisioned) there is no more data race possible: If setTargetLanguage should be called while translate is running I don't care: The thing that runs that code is not trying to access the same storage location anymore.
In this case I chose a static method (wrapped in an enum for purely code organizational reasons), but more complex designs are also possible (like having the method annotated to another global actor or residing in an actor in the first place). The point is that I "removed" the offending things that made sendability an issue, so in one sense I did not make "everything" sendable, but in another, I did?

This illustrates also why I find it so hard to give a short explanation of "how to do this". I cannot in good conscience say "make your types sendable" or "use detached tasks", because, ultimately, Swift's concurrency paradigm enforces you to rethink some established patterns and more thoroughly design your architecture.
I know it can be frustrating, but personally I have found the end result often times to be cleaner and even "prettier"? It kind of forces your nose right onto those places you would otherwise overlook, like "hey, actually I potentially access this storage from two different threads". The question is then less "how can I make that work?" and more "why am I doing this in the first place, is there maybe a way to avoid this at all?" Things become more encapsulated, more stable and, often, more readable (or at least "shorter") by doing that.


  1. as written, this might actually change with upcoming proposals, but for now this code will run the translate function in a different isolation context than main actor bound code ↩︎

2 Likes