top of page

Why use screen-owned ViewModels in our code?

Code from Trend • Full Xcode project ↗
import Foundation
import Observation

@MainActor
@Observable
final class ProgressViewModel {
    private let progress: ProgressManager

    init(progress: ProgressManager = AppModel.shared.progressFeature) {
        self.progress = progress
    }

    var snapshot: WeightHistoryData { progress.progressSnapshot }
    var goalKilograms: Double? { progress.goalWeightKilograms }
    var unit: WeightUnit { progress.selectedWeightUnit }
    var isLoading: Bool { progress.isPreparingProgress }
    var range: ProgressRange {
        get { progress.selectedProgressRange }
        set { progress.selectProgressRange(newValue) }
    }
}

Trend’s progress screen owns this ViewModel. Its initializer has one feature input, with a production default from AppModel.shared. Calling ProgressViewModel() connects the screen to the existing progress feature; it does not construct a new weight repository or load a database.

This is the CFA convention we want to make easy to recognise: a screen owns its presentation object, and that object establishes its narrow connection to the model.

The single Feature input in this example is sufficient for this screen, not a limit imposed by CFA. A screen can present several independent business capabilities. A Settings ViewModel might receive both SettingsFeature and UserProfileFeature, each with its own production default and isolated test dependency.

1. The approach

Code from Trend • Full Xcode project ↗
@State private var viewModel = ProgressViewModel()

This is the stored-property declaration inside Trend’s ProjectionView. The ViewModel’s initial value is declared on the same line. There is no init(viewModel:) and no parent-supplied ViewModel to put into State(initialValue:).

CFA’s documented rationale: a screen should be straightforward to construct, with its model connection defined in its own ViewModel. A parent should not need to assemble the screen’s internal dependencies. That is an architecture choice, not a claim that SwiftUI forbids other ownership patterns.

SwiftUI associates state with view identity, not with a promise that a View struct or its initial-value expression is evaluated only once. Keep that construction cheap. A ViewModel initializer that starts downloads or opens a store would make this simple-looking declaration conceal real work. Apple’s State documentation describes the framework-managed storage.

2. The problem it solves

Code from Trend • Full Xcode project ↗
struct RootView: View {
    @State private var viewModel = RootViewModel()
    @Environment(ThemeManager.self) private var themeManager

    var body: some View {
        @Bindable var viewModel = viewModel
        TabView {
            // Tab-bar preferences flow from each tab's content to its enclosing TabView.
            TodayView()
                .tabItem { Label("Today", systemImage: "plus.circle.fill") }
                .toolbarBackground(.ultraThinMaterial, for: .tabBar)
                .toolbarBackground(.visible, for: .tabBar)
            ProjectionView()
                .tabItem { Label("Trend", systemImage: "chart.xyaxis.line") }
                .toolbarBackground(.ultraThinMaterial, for: .tabBar)
                .toolbarBackground(.visible, for: .tabBar)
            HabitsView()
                .tabItem { Label("Habits", systemImage: "scope") }
                .toolbarBackground(.ultraThinMaterial, for: .tabBar)
                .toolbarBackground(.visible, for: .tabBar)
            SettingsView()
                .tabItem { Label("Settings", systemImage: "gearshape") }
                .toolbarBackground(.ultraThinMaterial, for: .tabBar)
                .toolbarBackground(.visible, for: .tabBar)
        }
        .background(themeManager.palette.background.ignoresSafeArea())
        .tint(themeManager.palette.accent)
        .onOpenURL { viewModel.open($0) }
        .sheet(item: $viewModel.importSelection, onDismiss: viewModel.importDismissed) { selection in
            WeightImportView(url: selection.url)
        }
    }
}

Here is Trend’s complete root navigation View. ProjectionView() is placed in a tab without supplying AppModel, a feature or a ViewModel. The navigation layer chooses which screens exist; it does not configure the private machinery of each screen.

Passing ViewModels down through every parent couples navigation to implementation details. Changing the dependencies of the progress screen then forces edits in code whose only job was to show that screen. It also becomes unclear whether two screens were meant to share one presentation object.

The root’s import sheet is an older counterexample: it passes a URL into a feature screen. Current CFA would route that selection through the appropriate feature. We are following the already-correct ProjectionView() path here, not recommending every historical initializer in this file.

3. Why it works

Code from Trend • Full Xcode project ↗
let progressFeature = ProgressManager(
    progress: progress,
    weightLog: weightLog,
    settings: settings
)

AppModel’s live construction creates the progress feature once and connects its collaborators. The ViewModel’s default initializer obtains that existing feature. The two lifetimes are different: the feature belongs to the application model; the ViewModel belongs to this screen’s presentation.

Two progress screens can therefore use the same underlying measurements without being forced to share every presentation detail. A short-lived selection or error message can belong to a screen; durable feature state stays in the model. Do not use the production singleton as a place to store navigation or temporary editor input.

Code from Trend • Full Xcode project ↗
TrendChartView(data: TrendChartData(
    snapshot: viewModel.snapshot,
    goalKilograms: viewModel.goalKilograms,
    unit: viewModel.unit
))

This chart receives a snapshot and display values. It is a reusable drawing component, not the feature screen’s backing object. The zero-input convention is about model-connected screens, not a ban on passing plain data to every SwiftUI View.

4. Advantages of using it

Code from Trend • Full Xcode project ↗
@Test func exposesSnapshotGoalUnitAndLoadingState() async {
    let now = Date()
    let entries = [
        WeightEntry(date: now.addingTimeInterval(-86_400), kilograms: 82),
        WeightEntry(date: now, kilograms: 80)
    ]
    let repository = InMemoryWeightRepository(store: .init(entries: entries, goalKilograms: 75))
    let appModel = TestAppModelFactory.make(repository: repository, unit: .pounds)
    await appModel.weightEntries.refresh()
    let viewModel = ProgressViewModel(progress: appModel.progressFeature)

    #expect(viewModel.snapshot.points.map(\.kilograms) == [82, 80])
    #expect(viewModel.snapshot.changeKilograms == -2)
    #expect(viewModel.goalKilograms == 75)
    #expect(viewModel.unit == .pounds)
    #expect(!viewModel.isLoading)
}

The test supplies an isolated concrete feature directly to the ViewModel. It does not need to add a View initializer just to make testing possible. The same initializer has a convenient production default and an explicit test connection.

Editorial trade-off: the production dependencies are less visible at the View call site. CFA compensates by keeping their defaults in one obvious ViewModel initializer and by limiting the connections to the Features the screen actually needs. This is not permission for arbitrary objects to look up unrelated services globally.

For a growing ViewModel, ask: is it arranging existing capabilities for this screen, or implementing a new business capability itself? Only the latter points towards extracting business behaviour into the model. Keep presentation decisions in the presentation layer, using a focused helper when necessary. Several Feature dependencies alone do not justify another Feature or facade.

The result is simple screen composition, local presentation ownership and a testable model connection. Review all three together: the stored property in the View, the feature-only initializer in the ViewModel, and the shared feature construction in AppModel. A tidy declaration alone cannot guarantee cheap construction or correct lifetime.

Try this with the CFA AI Toolkit for iOS:

Using CFA for iOS, review this screen’s ViewModel ownership without changing navigation behaviour.

Scope: code from the linked app revisions; smaller excerpts belong to their containing types and targets. The articles distinguish older implementations from current CFA conventions. Source verification is not a full app build or device test. Suggested checks are guidance, not claims they ran against your application.

bottom of page