CFA Reference: ViewModels and Screens
Formal CFA AI Toolkit for iOS Documentation · Reference 13 of 26
Every unique screen in CFA has its own adjacent ViewModel. The View is declarative; the ViewModel holds screen presentation state and routes user intent to the Feature.
Definitions
- Dedicated ViewModel: An @MainActor @Observable class used by exactly one screen, named after it, for example HabitsViewModel for HabitsView.
- Inline construction: The View owns its ViewModel with
@State private var viewModel = HabitsViewModel(). - Narrow Feature dependency: The ViewModel initialiser defaults the Features it needs from AppModel.shared, for example habitsFeature: HabitsFeature = AppModel.shared.habitsFeature.
- Presentation state: State that exists only for the screen: draft text, a presented sheet, a save error message.
Rules
- Each screen owns a private dedicated ViewModel, constructed inline; never pass in or share a ViewModel (change gate rule 1).
- ViewModels default narrow Feature dependencies from AppModel.shared; tests provide isolated Features (rule 2).
- Views remain declarative. Bundle and system reads and presentation formatting belong to the ViewModel; business, persistence and shared state belong to the Feature (rule 11).
- ViewModel methods name their work: refresh(), save(), retry(); never refreshTapped(), savePressed() or screenAppeared() (rule 19).
- A ViewModel retains handles for the Tasks it creates; repeated actions reuse and await the existing execution (rule 22).
- A View never stores a Task handle and never receives a ViewModel, Feature, AppModel or repository.
- RootTabView is structural only: it presents tabs and navigation and does not refresh Features (rule 6).
Decision rules
- When a screen grows, ask whether it is arranging existing capabilities (keep it in the ViewModel) or implementing a new business capability (move it to a Feature).
- When a detail screen needs its item, pass the selected value into the View's initialiser.
- When a Button triggers async work, call a ViewModel method that returns the retained task.
- When a screen needs initial data, use .task on that screen to await the ViewModel's work.
Example
The compiled ViewModel below holds only presentation state, reads the Feature through computed properties, and reuses one refresh task.
Compiled example (excerpt)
import Foundation
import Observation
// MARK: Screen ViewModel
@MainActor
@Observable
final class HabitsViewModel {
private let habitsFeature: HabitsFeature
@ObservationIgnored private var refreshTask: Task<Void, Never>?
var draftName = ""
private(set) var saveError: String?
init(habitsFeature: HabitsFeature = AppModel.shared.habitsFeature) {
self.habitsFeature = habitsFeature
}
var habitNames: [String] { habitsFeature.habits.map(\.name) }
var isLoading: Bool { habitsFeature.isLoading }
var loadError: String? { habitsFeature.loadError }
/// Repeated presses reuse the one retained task.
@discardableResult
func refresh() -> Task<Void, Never> {
if let refreshTask { return refreshTask }
let habitsFeature = habitsFeature
let newRefreshTask = Task {
await habitsFeature.refresh()
self.refreshTask = nil
}
refreshTask = newRefreshTask
return newRefreshTask
}
func save() async {
do {
try await habitsFeature.addHabit(named: draftName)
draftName = ""
saveError = nil
} catch {
saveError = "The habit could not be saved."
}
}
}Compiled with Swift 6 language mode and warnings treated as errors: type-checked on Swift 6.2 and built on Swift 6.4 (Linux toolchains, Foundation and Observation only).
Illustrative SwiftUI View for the ViewModel above. It uses SwiftUI, so it was not compiled for this page.
struct HabitsView: View {
@State private var viewModel = HabitsViewModel()
var body: some View {
List(viewModel.habitNames, id: \.self) { habitName in
Text(habitName)
}
.refreshable { await viewModel.refresh().value }
.task { await viewModel.refresh().value }
}
}
Common mistakes
- Passing a ViewModel into a View from its parent.
- Sharing one ViewModel between two screens.
- Storing a copy of the Feature's habits in the ViewModel.
- Creating a new Task in the View's Button action every time it is pressed.
- Naming a method onAppear() or refreshTapped().
