Skill: ViewModel
Guide for creating ViewModels that manage state and coordinate between Views and UseCases.
When to use this skill
- •Create a new ViewModel for a feature
- •Add state management with ViewState pattern
- •Create ViewModel tests
File structure
Features/{Feature}/
├── Sources/
│ └── Presentation/
│ └── {ScreenName}/ # Group by screen (e.g., CharacterDetail)
│ ├── Navigator/
│ │ ├── {ScreenName}NavigatorContract.swift # Navigator protocol
│ │ └── {ScreenName}Navigator.swift # Navigator implementation
│ ├── Tracker/
│ │ ├── {ScreenName}TrackerContract.swift # Tracker protocol
│ │ ├── {ScreenName}Tracker.swift # Tracker implementation
│ │ └── {ScreenName}Event.swift # Tracking events
│ ├── Views/
│ │ └── {ScreenName}View.swift
│ └── ViewModels/
│ ├── {ScreenName}ViewState.swift # ViewState enum
│ └── {ScreenName}ViewModel.swift # ViewModel
└── Tests/
└── Presentation/
└── {ScreenName}/ # Same structure as Sources
├── Navigator/
│ └── {ScreenName}NavigatorTests.swift
├── Tracker/
│ ├── {ScreenName}TrackerTests.swift
│ └── {ScreenName}EventTests.swift
└── ViewModels/
└── {ScreenName}ViewModelTests.swift
Examples:
- •
Presentation/CharacterDetail/ViewModels/CharacterDetailViewModel.swift - •
Presentation/CharacterDetail/Navigation/CharacterDetailNavigator.swift - •
Presentation/CharacterList/ViewModels/CharacterListViewModel.swift - •
Tests/Presentation/CharacterDetail/ViewModels/CharacterDetailViewModelTests.swift
ViewState Pattern
Use an enum to represent all possible states of a view:
enum {Name}ViewState {
case idle
case loading
case loaded({Name})
case error(Error)
static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle), (.loading, .loading):
true
case let (.loaded(lhsData), .loaded(rhsData)):
lhsData == rhsData
case let (.error(lhsError), .error(rhsError)):
lhsError.localizedDescription == rhsError.localizedDescription
default:
false
}
}
}
Rules:
- •Internal visibility (not public)
- •One state enum per ViewModel
- •
idleis the initial state - •
loadedcontains the data - •
errorcontains the Error - •Implement
==operator for testability (enables direct comparison in tests)
For lists:
enum {Name}ListViewState {
case idle
case loading
case loaded([{Name}])
case empty
case error(Error)
static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle), (.loading, .loading), (.empty, .empty):
true
case let (.loaded(lhsData), .loaded(rhsData)):
lhsData == rhsData
case let (.error(lhsError), .error(rhsError)):
lhsError.localizedDescription == rhsError.localizedDescription
default:
false
}
}
}
ViewModel Pattern (Detail - no navigation)
import Foundation
@Observable
final class {Name}DetailViewModel {
private(set) var state: {Name}ViewState = .idle
private let get{Name}UseCase: Get{Name}UseCaseContract
init(get{Name}UseCase: Get{Name}UseCaseContract) {
self.get{Name}UseCase = get{Name}UseCase
}
func load(id: Int) async {
state = .loading
do {
let result = try await get{Name}UseCase.execute(id: id)
state = .loaded(result)
} catch {
state = .error(error)
}
}
}
ViewModel Pattern (List)
import Foundation
@Observable
final class {Name}ListViewModel {
private(set) var state: {Name}ListViewState = .idle
private let get{Name}sUseCase: Get{Name}sUseCaseContract
init(get{Name}sUseCase: Get{Name}sUseCaseContract) {
self.get{Name}sUseCase = get{Name}sUseCase
}
func didAppear() async {
await load()
}
func didTapOnRetryButton() async {
await load()
}
}
// MARK: - Private
private extension {Name}ListViewModel {
func load() async {
state = .loading
do {
let items = try await get{Name}sUseCase.execute()
state = items.isEmpty ? .empty : .loaded(items)
} catch {
state = .error(error)
}
}
}
Rules:
- •
@Observablefor SwiftUI integration (iOS 17+) - •
final classto prevent subclassing - •Internal visibility (not public)
- •Inject UseCases via protocol (contract)
- •State is
private(set)- only ViewModel mutates it - •Inject
NavigatorContractfor navigation (see/routerskill) - •
didAppear()anddidTapOnRetryButton()are public,load()is private (see "Protocol Method Naming Convention")
ViewModel Pattern (with Navigation and Tracking)
ViewModels that trigger navigation receive a NavigatorContract and a TrackerContract:
import Foundation
@Observable
final class {Name}ListViewModel {
private(set) var state: {Name}ListViewState = .idle
private let get{Name}sUseCase: Get{Name}sUseCaseContract
private let navigator: {Name}ListNavigatorContract
private let tracker: {Name}ListTrackerContract
init(
get{Name}sUseCase: Get{Name}sUseCaseContract,
navigator: {Name}ListNavigatorContract,
tracker: {Name}ListTrackerContract
) {
self.get{Name}sUseCase = get{Name}sUseCase
self.navigator = navigator
self.tracker = tracker
}
func load() async {
state = .loading
do {
let items = try await get{Name}sUseCase.execute()
state = items.isEmpty ? .empty : .loaded(items)
} catch {
state = .error(error)
}
}
// Semantic navigation methods
func didSelectItem(_ item: {Name}) {
navigator.navigateToDetail(id: item.id)
}
func didTapOnBack() {
navigator.goBack()
}
}
Rules:
- •Inject NavigatorContract (not RouterContract directly)
- •Inject TrackerContract for screen-specific tracking
- •Use semantic method names:
didTapOn...,didSelect... - •Never expose navigator or tracker to View
- •Navigator handles internal vs external navigation details
- •Tracker handles event dispatching to the core tracker
- •See
/navigatorskill for Navigator pattern documentation
ViewModel Pattern (Stateless - navigation only)
ViewModels that only trigger navigation (no observable state) don't need @Observable:
/// Not @Observable: no state for the view to observe, only exposes actions.
final class {Name}ViewModel {
private let navigator: {Name}NavigatorContract
private let tracker: {Name}TrackerContract
init(navigator: {Name}NavigatorContract, tracker: {Name}TrackerContract) {
self.navigator = navigator
self.tracker = tracker
}
func didAppear() {
tracker.trackScreenViewed()
}
func didTapOn{Action}() {
tracker.track{Action}ButtonTapped()
navigator.navigateTo{Destination}()
}
}
When to use:
- •ViewModel has no state for the View to observe
- •ViewModel only exposes action methods (navigation, tracking, triggers)
- •View uses
let viewModelinstead of@State private var viewModel
Example: HomeViewModel
/// Not @Observable: no state for the view to observe, only exposes actions.
final class HomeViewModel {
private let navigator: HomeNavigatorContract
private let tracker: HomeTrackerContract
init(navigator: HomeNavigatorContract, tracker: HomeTrackerContract) {
self.navigator = navigator
self.tracker = tracker
}
func didAppear() {
tracker.trackScreenViewed()
}
func didTapOnCharacterButton() {
tracker.trackCharacterButtonTapped()
navigator.navigateToCharacters()
}
}
Corresponding View:
struct HomeView: View {
/// Not @State: ViewModel has no observable state, just actions.
let viewModel: HomeViewModel
var body: some View {
Button("Go to Characters") {
viewModel.didTapOnCharacterButton()
}
}
}
Protocol Method Naming Convention
All protocol methods describe the UI event that triggers them, using the did prefix:
| UI Event | Protocol Method |
|---|---|
View appears (.onFirstAppear {}) | didAppear() |
| Tap on retry button | didTapOnRetryButton() |
Pull to refresh (.refreshable {}) | didPullToRefresh() |
| Tap on "Load More" button | didTapOnLoadMoreButton() |
| Item selection | didSelect(_:) |
| Tap on button | didTapOn{ButtonName}() |
Behavior Rules
- •
didAppear(): Called once via.onFirstAppearin the View. The.onFirstAppearmodifier guarantees single execution, so the ViewModel does not need to guard against re-execution. - •
didTapOnRetryButton(): Always callsload()unconditionally. The user has explicitly decided to retry. - •
didPullToRefresh(): Always calls the refresh use case. Resets pagination to page 1. - •
didTapOnLoadMoreButton(): Only loads if there is a next page and not already loading more.
Preventing Unnecessary Reloads
The .onFirstAppear modifier (from ChallengeCore) executes only once when the view first appears, preventing unnecessary data reloads when returning from navigation. The ViewModel does not need to guard against re-execution because .onFirstAppear guarantees single invocation.
Pattern: didAppear() + didTapOnRetryButton() + private load()
@Observable
final class {Name}ListViewModel {
private(set) var state: {Name}ListViewState = .idle
// Public: View calls this from .onFirstAppear - executes once
func didAppear() async {
await load()
}
// Public: View calls this from retry button - always reloads
func didTapOnRetryButton() async {
await load()
}
}
private extension {Name}ListViewModel {
// Private: Only called internally
func load() async {
state = .loading
// fetch data...
}
}
Rules:
- •
didAppear()is public - called by View in.onFirstAppear, guaranteed to execute once - •
didTapOnRetryButton()is public - called by View in error retry button, always loads - •
load()is private - encapsulates loading logic - •Single execution is guaranteed by
.onFirstAppearin the View layer - •Error retry is the user's explicit choice via
didTapOnRetryButton()
View Integration
struct {Name}ListView: View {
@State private var viewModel: {Name}ListViewModel
var body: some View {
content
.onFirstAppear {
await viewModel.didAppear()
}
}
}
Observable Properties with Guards
When using observable properties that trigger actions (like search), guard against unchanged values:
var searchQuery: String = "" {
didSet {
if searchQuery != oldValue {
searchQueryDidChange()
}
}
}
Why: SwiftUI may re-set binding values during navigation transitions, triggering didSet even when the value hasn't changed. The guard prevents unnecessary reloads.
Testing didAppear() and didTapOnRetryButton()
Test state transitions:
@Test("didTapOnRetryButton retries loading when in error state")
func didTapOnRetryButtonRetriesWhenError() async {
// Given
useCaseMock.result = .failure(.loadFailed)
await sut.didAppear()
// When
useCaseMock.result = .success(.stub())
await sut.didTapOnRetryButton()
// Then
#expect(useCaseMock.executeCallCount == 2)
}
@Test("didTapOnRetryButton always loads regardless of current state")
func didTapOnRetryButtonAlwaysLoads() async {
// Given
useCaseMock.result = .success(.stub())
await sut.didAppear()
// When
await sut.didTapOnRetryButton()
// Then
#expect(useCaseMock.executeCallCount == 2)
}
Pull-to-Refresh Pattern
Pull-to-refresh requires different strategies for lists vs details. Use separate Get and Refresh UseCases to follow the Single Responsibility Principle.
List Refresh (Remote First)
For lists, use a dedicated RefreshUseCase that fetches from remote:
@Observable
final class {Name}ListViewModel {
private let get{Name}sUseCase: Get{Name}sUseCaseContract // localFirst
private let refresh{Name}sUseCase: Refresh{Name}sUseCaseContract // remoteFirst
func didPullToRefresh() async {
currentPage = 1
await refreshData()
}
}
private extension {Name}ListViewModel {
func refreshData() async {
do {
let result = try await refresh{Name}sUseCase.execute(page: currentPage)
state = result.items.isEmpty ? .empty : .loaded(result)
} catch {
state = .error(error)
}
}
}
Detail Refresh (Remote First)
For details, use separate Get (localFirst) and Refresh (remoteFirst) UseCases:
@Observable
final class {Name}DetailViewModel {
private let identifier: Int
private let get{Name}DetailUseCase: Get{Name}DetailUseCaseContract // localFirst
private let refresh{Name}DetailUseCase: Refresh{Name}DetailUseCaseContract // remoteFirst
init(
identifier: Int,
get{Name}DetailUseCase: Get{Name}DetailUseCaseContract,
refresh{Name}DetailUseCase: Refresh{Name}DetailUseCaseContract,
navigator: {Name}DetailNavigatorContract,
tracker: {Name}DetailTrackerContract
) { ... }
func didPullToRefresh() async {
do {
let item = try await refresh{Name}DetailUseCase.execute(identifier: identifier)
state = .loaded(item)
} catch {
state = .error(error)
}
}
}
IMPORTANT:
- •Get UseCases use
localFirstcache policy (fast initial load)- •Refresh UseCases use
remoteFirstcache policy (pull-to-refresh)- •ViewModels don't know about cache policies - they just call the appropriate UseCase
- •See
/usecaseskill for naming conventions and implementation details
View Integration
ScrollView {
// content...
}
.refreshable {
await viewModel.didPullToRefresh()
}
Testing List Refresh
@Test("didPullToRefresh calls refresh use case")
func didPullToRefreshCallsRefreshUseCase() async {
// Given
refreshUseCaseMock.result = .success(.stub())
// When
await sut.didPullToRefresh()
// Then
#expect(refreshUseCaseMock.executeCallCount == 1)
}
Testing Detail Refresh
@Test("didPullToRefresh updates from API using refresh use case")
func didPullToRefreshUpdatesFromAPI() async {
// Given
getUseCaseMock.result = .success(.stub())
await sut.didAppear()
refreshUseCaseMock.result = .success(.stub(name: "Refreshed"))
// When
await sut.didPullToRefresh()
// Then
#expect(refreshUseCaseMock.executeCallCount == 1)
}
Testing
ViewModel Tests
import Foundation
import Testing
@testable import {AppName}{Feature}
struct {Name}ViewModelTests {
@Test
func initialStateIsIdle() {
// Given
let useCaseMock = Get{Name}UseCaseMock()
let navigatorMock = {Name}NavigatorMock()
let trackerMock = {Name}TrackerMock()
let sut = {Name}ViewModel(get{Name}UseCase: useCaseMock, navigator: navigatorMock, tracker: trackerMock)
// Then
#expect(sut.state == .idle)
}
@Test
func loadSetsLoadedStateOnSuccess() async {
// Given
let expected = {Name}.stub()
let useCaseMock = Get{Name}UseCaseMock()
useCaseMock.result = .success(expected)
let navigatorMock = {Name}NavigatorMock()
let trackerMock = {Name}TrackerMock()
let sut = {Name}ViewModel(get{Name}UseCase: useCaseMock, navigator: navigatorMock, tracker: trackerMock)
// When
await sut.load(id: 1)
// Then
#expect(sut.state == .loaded(expected))
}
@Test
func loadSetsErrorStateOnFailure() async {
// Given
let useCaseMock = Get{Name}UseCaseMock()
useCaseMock.result = .failure(TestError.network)
let navigatorMock = {Name}NavigatorMock()
let trackerMock = {Name}TrackerMock()
let sut = {Name}ViewModel(get{Name}UseCase: useCaseMock, navigator: navigatorMock, tracker: trackerMock)
// When
await sut.load(id: 1)
// Then
#expect(sut.state == .error(TestError.network))
}
@Test
func loadCallsUseCaseWithCorrectId() async {
// Given
let useCaseMock = Get{Name}UseCaseMock()
useCaseMock.result = .success(.stub())
let navigatorMock = {Name}NavigatorMock()
let trackerMock = {Name}TrackerMock()
let sut = {Name}DetailViewModel(get{Name}UseCase: useCaseMock, navigator: navigatorMock, tracker: trackerMock)
// When
await sut.load(id: 42)
// Then
#expect(useCaseMock.executeCallCount == 1)
#expect(useCaseMock.lastRequestedId == 42)
}
@Test
func didSelectItemNavigatesToDetail() {
// Given
let useCaseMock = Get{Name}UseCaseMock()
let navigatorMock = {Name}NavigatorMock()
let trackerMock = {Name}TrackerMock()
let sut = {Name}ViewModel(get{Name}UseCase: useCaseMock, navigator: navigatorMock, tracker: trackerMock)
// When
sut.didSelectItem({Name}(id: 42))
// Then
#expect(navigatorMock.navigateToDetailIds == [42])
}
@Test
func didTapOnBackCallsNavigator() {
// Given
let useCaseMock = Get{Name}UseCaseMock()
let navigatorMock = {Name}NavigatorMock()
let trackerMock = {Name}TrackerMock()
let sut = {Name}ViewModel(get{Name}UseCase: useCaseMock, navigator: navigatorMock, tracker: trackerMock)
// When
sut.didTapOnBack()
// Then
#expect(navigatorMock.goBackCallCount == 1)
}
@Test
func didAppearTracksScreenViewed() async {
// Given
let useCaseMock = Get{Name}UseCaseMock()
useCaseMock.result = .success(.stub())
let navigatorMock = {Name}NavigatorMock()
let trackerMock = {Name}TrackerMock()
let sut = {Name}ViewModel(get{Name}UseCase: useCaseMock, navigator: navigatorMock, tracker: trackerMock)
// When
await sut.didAppear()
// Then
#expect(trackerMock.screenViewedCallCount == 1)
}
}
private enum TestError: Error {
case network
}
Testing Rules:
- •Use direct comparison for state assertions when possible:
#expect(sut.state == .idle) - •ViewState must implement
==operator for direct comparison - •Test initial state, success, error, and call verification
- •Use NavigatorMock to verify navigation calls (not RouterMock)
- •Use TrackerMock to verify tracking calls
Example: CharacterListViewModel
ViewState
// Sources/Presentation/CharacterList/ViewModels/CharacterListViewState.swift
enum CharacterListViewState {
case idle
case loading
case loaded(CharactersPage) // Use custom type for pagination support
case empty
case error(Error)
static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle), (.loading, .loading), (.empty, .empty):
true
case let (.loaded(lhsPage), .loaded(rhsPage)):
lhsPage == rhsPage
case let (.error(lhsError), .error(rhsError)):
lhsError.localizedDescription == rhsError.localizedDescription
default:
false
}
}
}
Note: For lists with pagination, use a custom type like
CharactersPagethat includes pagination metadata (currentPage, totalPages, hasNextPage, etc.). For simple lists without pagination, use[{Name}]directly.
ViewModel
// Sources/Presentation/CharacterList/ViewModels/CharacterListViewModel.swift
import Foundation
@Observable
final class CharacterListViewModel {
private(set) var state: CharacterListViewState = .idle
private let getCharactersUseCase: GetCharactersUseCaseContract
private let navigator: CharacterListNavigatorContract
private let tracker: CharacterListTrackerContract
init(
getCharactersUseCase: GetCharactersUseCaseContract,
navigator: CharacterListNavigatorContract,
tracker: CharacterListTrackerContract
) {
self.getCharactersUseCase = getCharactersUseCase
self.navigator = navigator
self.tracker = tracker
}
func didAppear() async {
tracker.trackScreenViewed()
await load()
}
func didTapOnRetryButton() async {
tracker.trackRetryButtonTapped()
await load()
}
func didSelect(_ character: Character) {
tracker.trackCharacterSelected(identifier: character.id)
navigator.navigateToDetail(id: character.id)
}
}
// MARK: - Private
private extension CharacterListViewModel {
func load() async {
state = .loading
do {
let result = try await getCharactersUseCase.execute(page: 1)
state = result.characters.isEmpty ? .empty : .loaded(result)
} catch {
state = .error(error)
}
}
}
Visibility Summary
| Component | Visibility | Location |
|---|---|---|
| ViewState | internal | Sources/Presentation/{ScreenName}/ViewModels/ |
| ViewModel | internal | Sources/Presentation/{ScreenName}/ViewModels/ |
| NavigatorContract | internal | Sources/Presentation/{ScreenName}/Navigator/ |
| Navigator | internal | Sources/Presentation/{ScreenName}/Navigator/ |
| TrackerContract | internal | Sources/Presentation/{ScreenName}/Tracker/ |
| Tracker | internal | Sources/Presentation/{ScreenName}/Tracker/ |
| Event | internal | Sources/Presentation/{ScreenName}/Tracker/ |
Checklist
- • Create ViewState enum with idle, loading, loaded, error cases
- • Create ViewModel class with @Observable
- • Inject UseCase via protocol (contract)
- • Inject NavigatorContract for navigation (not RouterContract)
- • Inject TrackerContract for screen-specific tracking
- • Implement
didAppear()anddidTapOnRetryButton()as public,load()as private - • Add tracking calls in
didAppear(),didSelect(),didTapOn...()methods - • Guard observable properties with
oldValuecheck indidSet - • Create tests for initial state, success, error, and call verification
- • Create tests for
didAppear()anddidTapOnRetryButton()behavior - • Create NavigatorMock for testing navigation
- • Create TrackerMock for testing tracking calls
- • Run tests