6.8 KiB
6.8 KiB
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
@MainActoron the class definition - State machines for complex flows (prefer enum over multiple booleans)
Rule 3: Dependency injection
- MUST inject collaborators (see 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:
onprefix (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
enum LoadingState<T: Equatable>: Equatable {
case idle
case loading
case loaded(T)
case failed(Error)
static func == (lhs: LoadingState<T>, rhs: LoadingState<T>) -> 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<Void, Never>?
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
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
@Observable @MainActor
final class SearchViewModel {
private(set) var searchText = ""
private(set) var results: [SearchResult] = []
private var searchTask: Task<Void, Never>?
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
// 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
// Wrong: anyone can mutate
var tiles: [Tile] = []
// Right: private setter
private(set) var tiles: [Tile] = []
Scattered state
// 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<T: Equatable>: Equatable {
case idle, loading, loaded(T), failed(Error)
}
private(set) var state: LoadingState<[Tile]> = .idle
View reference in ViewModel
// Wrong: ViewModel knows about View
weak var view: MyView?
// Right: ViewModel exposes state
private(set) var errorMessage: String?
Testing
@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