Actor Boundaries in Swift: Keeping View Models Focused on UI State
Marking a view model @MainActor is a sound default because the state observed by the interface should change in one isolation domain. The design becomes less useful when that view model also owns network request coalescing, a mutable cache, file writes, decoding, navigation, and error presentation. Every responsibility is pulled onto the main actor, and tests must interact with one oversized object.
A clearer boundary is based on ownership. MainActor owns presentation state. An actor owns a shared mutable resource with an independent lifetime. Stateless transformation uses sendable values and ordinary functions.
Use an actor because it owns mutable state
The repository below owns a cache that may be reached by several tasks. That is the reason for making it an actor. The network client is a small injected interface, so a test can replace it.
struct Article: Sendable, Identifiable {
let id: Int
let title: String
}
protocol ArticleClient: Sendable {
func fetchArticles() async throws -> [Article]
}
actor ArticleRepository {
private let client: any ArticleClient
private var cache: [Article]?
init(client: any ArticleClient) {
self.client = client
}
func articles(forceRefresh: Bool = false) async throws -> [Article] {
if !forceRefresh, let cache {
return cache
}
let values = try await client.fetchArticles()
cache = values
return values
}
}
Do not turn every repository into an actor by convention. A completely stateless repository may only need a Sendable client or a function. Actors serialize access to their isolated state; unnecessary serialization can hide the true data flow and introduce reentrancy questions without protecting a resource.
Remember that an actor method is reentrant at an await. While fetchArticles() is suspended, another call may enter articles. If request coalescing is required, represent an in-flight task explicitly and define how cancellation by one caller affects other waiters.
Let the view model translate into screen state
@MainActor
final class ArticlesViewModel: ObservableObject {
enum State {
case idle, loading, loaded([Article]), failed(String)
}
@Published private(set) var state: State = .idle
private let repository: ArticleRepository
private var requestID = UUID()
init(repository: ArticleRepository) {
self.repository = repository
}
func load(forceRefresh: Bool = false) async {
let current = UUID()
requestID = current
state = .loading
do {
let articles = try await repository.articles(forceRefresh: forceRefresh)
guard requestID == current else { return }
state = .loaded(articles)
} catch is CancellationError {
guard requestID == current else { return }
state = .idle
} catch {
guard requestID == current else { return }
state = .failed("Unable to load articles")
}
}
}
Request identity handles an important product race. If a refresh starts after an initial load, a late result from the initial request must not overwrite the newer result. The identifier is a final validation before committing state. It complements cancellation rather than replacing it, because some dependencies may finish despite a caller no longer needing their result.
The view can call load() from .task and .refreshable. Those modifiers give the work a structured lifetime connected to the interface. Cancellation can propagate naturally when the view disappears or a task is superseded.
Avoid accidental unstructured ownership
If load() immediately creates Task { ... } and returns, its caller cannot await completion and may not know who cancels the work. Prefer making the operation itself async. Create and store a task only when the object deliberately owns work that must outlive one call, and cancel that handle when a replacement begins or the owner shuts down.
Task.detached is not a general escape hatch for actor diagnostics. It drops the surrounding structured relationship and does not inherit all context in the same way. Use it only when the work genuinely requires an independent task. For CPU-heavy operations, introduce a service boundary and profile the main-actor impact rather than detaching code based on intuition.
Actors are reentrant at suspension points. Another task may enter while fetchArticles() is awaiting the network, so a condition checked before await is not guaranteed to remain true afterward. If the repository coalesces requests, it can store the in-flight task as state, then verify that task is still current before committing the cache and clearing the handle. Placing a long request inside one actor method does not make the network wait an indivisible transaction.
Translate errors at the boundary that understands the audience. The repository can preserve domain error categories useful for retry and diagnostics. The view model turns those categories into presentation state. The UI should not derive copy from a numeric URLError, and the repository should not construct localized alert text. That separation allows the same repository to support a widget, command-line tool, or another screen.
Test ordering, not implementation trivia
Test the repository cache separately from the presentation transitions. A controllable client can suspend two requests; complete the second request first and then the first. Assert that the stale result does not replace the current screen state. Test cancellation as a neutral transition rather than displaying it as a network failure.
These tests verify product rules that the compiler cannot choose. Static isolation prevents many data races, while tests define what should happen when legitimate asynchronous events arrive in surprising orders.
The goal of actor boundaries is not to make every type “concurrent.” It is to give each piece of shared mutable state an explicit owner. When the view model remains focused on presentation, UI isolation, resource serialization, and asynchronous lifetimes become independently understandable and testable.