swiftswiftuiasync-awaitcombinepublisher

How to solve Sending 'self.viewModel' risks causing data races?


I am looking to migrate my below file into swift 6. There is one way to forcefully use @MainActor everywhere(ViewModels, protocols etc) another is use uncheck-sendable or use @preconcurrency. But I am not looking to silence the warning I am looking to solve in proper thread mechanism. enter image description here

My code - Note use swift 6 from build settings to get error

import SwiftUI


// MARK: - Model

struct UserModel: Identifiable, Decodable {
    let id: Int
    let name: String
}

// MARK: - Protocol for User Service

protocol UserServiceProtocol {
    func fetchUsers() async throws -> [UserModel]
}

// MARK: - Real API Implementation

class UserService: UserServiceProtocol {
    func fetchUsers() async throws -> [UserModel] {
        let url = URL(string: "https://jsonplaceholder.typicode.com/users")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([UserModel].self, from: data)
    }
}

// MARK: - ViewModel Protocol

protocol UserListViewModelProtocol: ObservableObject {
    var users: [UserModel] { get }
    var isLoading: Bool { get }
    var error: String? { get }
    func loadUsers() async
}

// MARK: - ViewModel

class UserListViewModel: UserListViewModelProtocol {
    @Published private(set) var users: [UserModel] = []
    @Published private(set) var isLoading = false
    @Published private(set) var error: String? = nil

    private let userService: UserServiceProtocol

    init(userService: UserServiceProtocol = UserService()) {
        self.userService = userService
    }

    func loadUsers() async {
        isLoading = true
        error = nil
        do {
            users = try await userService.fetchUsers()
        } catch {
            self.error = error.localizedDescription
        }
        isLoading = false
    }
}

// MARK: - SwiftUI View

struct UserListView<VM: UserListViewModelProtocol>: View {
    @StateObject private var viewModel: VM

    init(viewModel: @autoclosure @escaping () -> VM) {
        _viewModel = StateObject(wrappedValue: viewModel())
    }

    var body: some View {
        NavigationView {
            Group {
                if viewModel.isLoading {
                    ProgressView("Loading...")
                } else if let error = viewModel.error {
                    Text("❌ \(error)")
                        .foregroundColor(.red)
                } else {
                    List(viewModel.users) { user in
                        Text(user.name)
                    }
                }
            }
            .navigationTitle("Users")
        }
        .task {
            await viewModel.loadUsers()//<<Error --- Sending 'self.viewModel' risks causing data races
        }
    }
}

// MARK: - Mock for Preview & Tests

class MockUserService: UserServiceProtocol {
    func fetchUsers() async throws -> [UserModel] {
        return [
            UserModel(id: 1, name: "Mock Alice"),
            UserModel(id: 2, name: "Mock Bob")
        ]
    }
}

// MARK: - Preview

struct UserListView_Previews: PreviewProvider {
    static var previews: some View {
        UserListView(viewModel: UserListViewModel(userService: MockUserService()))
    }
}

Solution

  • Since all properties of UserListViewModelProtocol need to bind to UI components such as ProgressView, Text, List. So, these properties should isolated to @MainActor.

    @MainActor // 👈
    protocol UserListViewModelProtocol: ObservableObject {
        ...
    }
    

    Now, the warning on the .task is resolved.

    .task {
        await viewModel.loadUsers() //<- ✅
    }
    

    However, your userService is a normal protocol and is not isolated to any actor. It has to be somehow, safety within isolation context. And Sendable is fit in this scenario.

    protocol UserServiceProtocol: Sendable { // 👈
        ... 
    }
    

    And the last one is the concrete type that conforms to UserServiceProtocol, which must be Sendable.

    final // 👈
    class UserService: UserServiceProtocol {
        ...
    }
    

    Finally, you will have: