# View Models How a Tiledown-style native editor structures its view models: thin coordination layers between views and services. They orchestrate business logic without containing it, maintain reactive UI state via `@Observable`, and stay testable through dependency injection. ## Mental model ViewModel = Coordinator (NOT a business-logic container) - Services: business logic - ViewModel: coordination + UI transformation - View: pure presentation ## Core rules ### Rule 1: ViewModel as coordinator - MUST delegate business logic to services - MUST NOT contain business rules or external access - Coordinate between layers only ### Rule 2: State management - MUST use `private(set)` for mutable state - MUST use enums for loading/async states (idle, loading, loaded, failed) - MUST derive computed state (no duplication) - MUST use `@MainActor` on the class definition - State machines for complex flows (prefer enum over multiple booleans) ### Rule 3: Dependency injection - MUST inject collaborators (see [dependency-injection.md](../dependency-injection.md)) - SHOULD prefer struct-based dependencies over protocols where practical - NO global singletons ### Rule 4: Task management - MUST support cancellation - MUST track loading states - MUST handle errors gracefully - Clean up in deinit ### Rule 5: Method naming - View-triggered: `on` prefix (e.g., `onTappedItem`) - Describe the user action, not the implementation - Internal methods: standard naming ### Rule 6: Testing design - MUST be testable without UI - MUST verify state transitions - NO timing dependencies ## Decision tree: code belongs in ViewModel? ``` Code belongs in ViewModel? ├─ UI state management? → YES ├─ Coordinating services? → YES ├─ Transforming for UI? → YES ├─ Business logic? → NO (Service) ├─ Data persistence? → NO (Repository) └─ Complex computation? → NO (Domain Service) ``` ## Patterns ### Basic structure ```swift enum LoadingState: Equatable { case idle case loading case loaded(T) case failed(Error) static func == (lhs: LoadingState, rhs: LoadingState) -> Bool { switch (lhs, rhs) { case (.idle, .idle), (.loading, .loading): return true case let (.loaded(l), .loaded(r)): return l == r case let (.failed(l), .failed(r)): return l.localizedDescription == r.localizedDescription default: return false } } } @Observable @MainActor final class TileListViewModel { // State (always private(set)) private(set) var state: LoadingState<[Tile]> = .idle // Computed (derive, don't duplicate) var tiles: [Tile] { if case .loaded(let tiles) = state { return tiles } return [] } var isLoading: Bool { if case .loading = state { return true } return false } var error: Error? { if case .failed(let error) = state { return error } return nil } // Injected dependencies private let service: TileService private var loadTask: Task? init(service: TileService) { self.service = service } deinit { loadTask?.cancel() } // User actions (on prefix) func onAppeared() { loadTask = Task { await loadTiles() } } // Private implementation private func loadTiles() async { state = .loading do { let tiles = try await service.fetchTiles() state = .loaded(tiles) } catch { state = .failed(error) } } } ``` ### State machine ```swift enum SessionState: Equatable { case signedOut, signingIn, signedIn(User), failed(Error) } @Observable @MainActor final class SessionViewModel { private(set) var state: SessionState = .signedOut private let sessionService: SessionService init(sessionService: SessionService) { self.sessionService = sessionService } var isAuthenticated: Bool { if case .signedIn = state { true } else { false } } func onSubmittedSignIn(email: String, password: String) async { guard case .signedOut = state else { return } state = .signingIn do { let user = try await sessionService.signIn(email, password) state = .signedIn(user) } catch { state = .failed(error) } } } ``` ### Search debounce ```swift @Observable @MainActor final class SearchViewModel { private(set) var searchText = "" private(set) var results: [SearchResult] = [] private var searchTask: Task? private let service: SearchService init(service: SearchService) { self.service = service } func onChangedSearchText(_ text: String) { searchText = text searchTask?.cancel() guard !text.isEmpty else { results = [] return } searchTask = Task { try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } results = try await service.search(text) } } } ``` ## Anti-patterns ### Business logic in ViewModel ```swift // Wrong: business logic in ViewModel func computeLayout(for tile: Tile) -> Layout { // Belongs in a service } // Right: delegate to service func onRequestedLayout(for tile: Tile) async -> Layout { await layoutService.layout(for: tile) } ``` ### Public mutable state ```swift // Wrong: anyone can mutate var tiles: [Tile] = [] // Right: private setter private(set) var tiles: [Tile] = [] ``` ### Scattered state ```swift // Wrong: scattered state makes invalid states possible private(set) var tiles: [Tile] = [] private(set) var isLoading = false private(set) var error: Error? // Can be: loading=true AND error != nil (invalid) // Right: single source of truth with enum enum LoadingState: Equatable { case idle, loading, loaded(T), failed(Error) } private(set) var state: LoadingState<[Tile]> = .idle ``` ### View reference in ViewModel ```swift // Wrong: ViewModel knows about View weak var view: MyView? // Right: ViewModel exposes state private(set) var errorMessage: String? ``` ## Testing ```swift @Test("Search updates correctly") func searchUpdate() async { let service = SearchService(search: { _ in [.init(id: "1")] }) let vm = SearchViewModel(service: service) // Initial state #expect(vm.results.isEmpty) // Trigger search await vm.onChangedSearchText("query") // Wait for debounce try? await Task.sleep(for: .milliseconds(400)) // Verify #expect(vm.results.count == 1) } ``` ## Checklist - [ ] All state uses `private(set)` - [ ] Dependencies injected through `init` - [ ] Business logic in services - [ ] User actions prefixed with `on` - [ ] Tasks cancellable - [ ] Errors transformed for UI - [ ] No force unwrapping - [ ] No view references - [ ] Computed properties for derived state - [ ] Task cleanup in deinit