Unstructured tasks need an owner—not a ban
A refresh button is a small piece of UI. The work it starts may not be small at all. Consider this familiar action:
Button("Refresh") {
Task {
await viewModel.refresh()
}
}There is nothing inherently invalid about starting a task from a synchronous button action. The missing information is architectural: who coordinates a second tap, who observes completion, and who decides whether this work should stop?
One tap is not necessarily one new operation
If the user taps Refresh three times while the same download is running, do they mean “download three copies”? Usually they mean “show me the latest items”. CFA makes that distinction explicit: a screen ViewModel retains the task it creates and returns the same handle for equivalent repeated actions.
That is a CFA convention, not a Swift compiler requirement. Swift lets an unstructured task continue even when its handle is discarded. Keeping the handle gives the owner a way to coordinate completion and request cancellation; it does not keep an otherwise-doomed task alive. See Swift’s structured concurrency proposal.
Make the screen’s owner visible
This ViewModel owns the interaction task and the screen’s error message. Its concrete ItemsFeature owns the item data and the download policy.
import Observation
@MainActor
@Observable
final class ItemsViewModel {
private let itemsFeature: ItemsFeature
private var refreshTask: Task<Void, Never>?
private(set) var refreshError: String?
var items: [Item] { itemsFeature.items }
var isRefreshing: Bool { refreshTask != nil }
init(itemsFeature: ItemsFeature = AppModel.shared.itemsFeature) {
self.itemsFeature = itemsFeature
}
@discardableResult
func refresh() -> Task<Void, Never> {
if let refreshTask { return refreshTask }
refreshError = nil
let task = Task { [weak self, itemsFeature] in
defer { self?.refreshTask = nil }
do {
try await itemsFeature.refresh()
} catch is CancellationError {
// Cancellation is not a failed download message.
} catch {
self?.refreshError = "Could not refresh your items. Try again."
}
}
refreshTask = task
return task
}
}The button can now express a single intent:
Button("Refresh") {
viewModel.refresh()
}The first call installs the handle. Further calls return it. The task clears its own handle when it finishes, including after an error, so a later retry can start. An asynchronous caller can use await viewModel.refresh().value to wait for that interaction to finish.
There is deliberately no replacement policy here: one refresh runs until completion. Because this method and its task are MainActor-isolated, the handle is installed before the task body can run. The task weakly references the ViewModel and directly retains the Feature it needs. This is not a promise that every captured dependency has a bounded lifetime; an operation that never finishes still needs its own timeout or lifecycle policy.
The Feature still protects the shared operation
A second screen has a different ViewModel. App-wide refresh can be another caller. Neither goes through this screen’s duplicate protection. The Feature must therefore define its own overlap policy. In this example, equivalent refresh requests share one download; the next explanation develops that implementation.
Keeping two handles is justified only because they represent different responsibilities: the screen interaction and the shared data operation. Do not add extra wrappers for every call in between.
Do not turn this into “every await needs a Task”
An async function can call another async function directly. Structured child work belongs to its parent operation. SwiftUI’s .task already provides a framework-managed lifetime for screen-related work. None needs an extra retained wrapper just to satisfy a naming rule.
If a lifecycle task awaits a separately owned shared refresh, cancelling the lifecycle task does not automatically define the shared refresh’s cancellation policy. The Feature must decide that. Apple documents the screen modifier’s behaviour in View.task.
What should the test prove?
Hold a download at a controlled suspension point. Request refresh twice and confirm only one download began. Release it and confirm both callers finish with the shared operation. Then make the dependency fail, confirm the message is honest and cached items remain available, and verify a subsequent refresh can succeed. Avoid sleeps that merely hope the calls overlapped.
Documented CFA rationale: named ownership makes completion, cleanup and cancellation policy visible. Editorial trade-off: retained handles add lifecycle obligations, so use them where there is a real lifetime to manage—not as decorative architecture.
A small prompt, a meaningful improvement
The useful change is not “remove Task”. It is “make ownership obvious”. With the CFA AI Toolkit for iOS available to your assistant, try:
Using CFA for iOS, tidy up the task ownership in this code.
Scope: focused Swift 6 examples with explicit MainActor isolation and Observation, for iOS 17+. The ViewModel depends on the companion ItemsFeature example and your AppModel composition; the snippets are not a complete app. CFA guidance is drawn from the toolkit’s written specification. Trade-offs are editorial analysis, not a reconstructed history of the architecture’s creation.