MVVM Architecture
What Is MVVM?
MVVM (Model-View-ViewModel) is an architectural pattern that separates an app into three layers:
- Model - Data types and business logic (User, Product, API response)
- View - The UI layer that displays data (SwiftUI views)
- ViewModel - The bridge that holds UI state and prepares data for the view
View <-> ViewModel <-> Model
The View observes the ViewModel. The ViewModel never references the View. This one-way dependency keeps layers decoupled and testable.
Why MVVM for SwiftUI?
SwiftUI naturally supports MVVM because:
- Views are structs that react to state changes
- @Observable creates observable objects
- ViewModels can be injected via @Environment or @Bindable
- The view body is a pure function of its state
Basic MVVM Structure
// Model
struct User: Codable, Identifiable {
let id: UUID
let name: String
let email: String
}
// ViewModel
@Observable
class UserListViewModel {
var users: [User] = []
var isLoading = false
var errorMessage: String?
private let apiService: ApiService
init(apiService: ApiService = LiveApiService()) {
self.apiService = apiService
}
func fetchUsers() async {
isLoading = true
defer { isLoading = false }
do {
users = try await apiService.fetchUsers()
} catch {
errorMessage = error.localizedDescription
}
}
}
// View
struct UserListView: View {
@State private var viewModel = UserListViewModel()
var body: some View {
List(viewModel.users) { user in
Text(user.name)
}
.overlay {
if viewModel.isLoading { ProgressView() }
}
.task { await viewModel.fetchUsers() }
}
}
Separation of Concerns
| Layer | Responsibility | Does NOT |
|---|---|---|
| View | Display UI, handle user input | Make API calls, store business logic |
| ViewModel | Hold UI state, format data, coordinate actions | Know about UIView or SwiftUI |
| Model | Represent data, encode/decode | Know about ViewModels or Views |
ViewModel as Single Source of Truth
The ViewModel owns all state the view needs:
@Observable
class ProductViewModel {
var products: [Product] = []
var searchText = ""
var selectedCategory: Category?
var sortOption: SortOption = .name
var filteredProducts: [Product] {
products
.filter { selectedCategory == nil || $0.category == selectedCategory }
.filter { searchText.isEmpty || $0.name.localizedCaseInsensitiveContains(searchText) }
.sorted(by: sortOption.comparator)
}
}
The view simply displays viewModel.filteredProducts without knowing the filtering logic.
Observable ViewModels
@Observable Macro (iOS 17+)
The @Observable macro is the modern way to create ViewModels:
@Observable
class ProfileViewModel {
var name = ""
var email = ""
var avatarURL: URL?
var isSaving = false
func save() async {
isSaving = true
defer { isSaving = false }
// save logic
}
}
@Observable automatically tracks which properties are read in the view body and only triggers updates when those specific properties change.
Legacy: ObservableObject with @Published
For iOS 16 and earlier:
class ProfileViewModel: ObservableObject {
@Published var name = ""
@Published var email = ""
@Published var avatarURL: URL?
@Published var isSaving = false
func save() async {
isSaving = true
defer { isSaving = false }
}
}
Injecting ViewModels
// Direct injection (preferred for most cases)
struct ProfileView: View {
@State private var viewModel = ProfileViewModel()
var body: some View {
Form {
TextField("Name", text: $viewModel.name)
TextField("Email", text: $viewModel.email)
}
.overlay {
if viewModel.isSaving { ProgressView() }
}
}
}
// Environment injection (for shared state)
@main
struct MyApp: App {
@State var userStore = UserStore()
var body: some Scene {
WindowGroup {
ContentView()
.environment(userStore)
}
}
}
ViewModel Coordination
For complex screens with multiple ViewModels:
@Observable
class CheckoutViewModel {
var cart: CartViewModel
var shipping: ShippingViewModel
var payment: PaymentViewModel
var isComplete: Bool {
cart.isNotEmpty && shipping.isValid && payment.isReady
}
func checkout() async {
guard isComplete else { return }
// coordinate checkout across all VMs
}
}
Avoiding Common Pitfalls
Do not store view-specific state in ViewModel:
// BAD: ViewModel knows about view presentation
@Observable
class SettingsViewModel {
var isShowingAlert = false // This is view state
var alertMessage = "" // This is view state
}
// GOOD: ViewModel exposes data, View manages presentation
@Observable
class SettingsViewModel {
var saveResult: SaveResult?
}
struct SettingsView: View {
@State var viewModel = SettingsViewModel()
@State var showAlert = false
var body: some View {
// View decides when to show alert
}
}
Data Flow in MVVM
View-to-ViewModel Flow
Views communicate with ViewModels through method calls and property mutations:
@Observable
class TodoViewModel {
var items: [TodoItem] = []
var newTitle = ""
func addItem() {
guard !newTitle.isEmpty else { return }
let item = TodoItem(id: UUID(), title: newTitle, isComplete: false)
items.append(item)
newTitle = ""
}
func toggleItem(_ item: TodoItem) {
if let index = items.firstIndex(where: { $0.id == item.id }) {
items[index].isComplete.toggle()
}
}
func deleteItems(at offsets: IndexSet) {
items.remove(atOffsets: offsets)
}
}
struct TodoView: View {
@State private var viewModel = TodoViewModel()
var body: some View {
NavigationStack {
List {
ForEach(viewModel.items) { item in
TodoRow(item: item) {
viewModel.toggleItem(item)
}
}
.onDelete(perform: viewModel.deleteItems)
}
.toolbar {
TextField("New item", text: $viewModel.newTitle)
Button("Add") { viewModel.addItem() }
}
}
}
}
ViewModel-to-View Flow
ViewModels notify views through property changes. Views observe and react:
@Observable
class SearchViewModel {
var query = ""
var results: [SearchResult] = []
var isLoading = false
func search() async {
isLoading = true
defer { isLoading = false }
results = await searchService.search(query: query)
}
}
struct SearchView: View {
@State var viewModel = SearchViewModel()
var body: some View {
VStack {
TextField("Search", text: $viewModel.query)
if viewModel.isLoading {
ProgressView()
} else {
List(viewModel.results) { result in
Text(result.title)
}
}
}
.task(id: viewModel.query) {
await viewModel.search()
}
}
}
Unidirectional Data Flow
MVVM in SwiftUI follows unidirectional data flow:
User Input -> View -> ViewModel (mutate state) -> View (re-renders)
The view never directly mutates model data. The ViewModel processes input, updates state, and the view automatically re-renders.
Testing ViewModels
ViewModels are easily testable because they do not depend on UIKit or SwiftUI:
@Observable
class UserViewModel {
var users: [User] = []
var error: String?
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func loadUsers() async {
do {
users = try await repository.fetchUsers()
} catch {
self.error = error.localizedDescription
}
}
}
// Test
func testLoadUsers() async {
let mockRepo = MockUserRepository()
mockRepo.usersToReturn = [User.mock]
let vm = UserViewModel(repository: mockRepo)
await vm.loadUsers()
XCTAssertEqual(vm.users.count, 1)
XCTAssertNil(vm.error)
}
When to Use MVVM
- Screens with business logic - filtering, validation, data transformation
- Network-heavy screens - API calls, data mapping, error handling
- Complex forms - multi-step validation, conditional fields
- Shared state - user session, settings, cart
For simple display-only views, MVVM is unnecessary overhead. Keep the ViewModel only when it adds clear value.
Quiz
1. What are the three layers in MVVM?
2. What does the ViewModel NOT reference?
3. How does the View react to ViewModel state changes in SwiftUI?
4. Why is MVVM testable?
5. Where should presentation state like alert visibility be managed?
Flashcards
Question
What does MVVM stand for?
Click to reveal answer
Answer
Model-View-ViewModel. Model holds data, View displays UI, ViewModel bridges them with state and logic.
Question
Does the ViewModel reference the View?
Click to reveal answer
Answer
No. The ViewModel never references the View. It exposes state; the View observes and reacts.
Question
How do you make a ViewModel observable in iOS 17+?
Click to reveal answer
Answer
Use the @Observable macro, which tracks property access and triggers granular view updates.
Question
Where does presentation state belong?
Click to reveal answer
Answer
In the View using @State. The ViewModel provides data; the View manages presentation (alerts, sheets).
Question
When should you use MVVM?
Click to reveal answer
Answer
When screens have business logic, network calls, complex forms, or shared state.
Revision Notes
Key Takeaways
- 1. MVVM separates Model, View, and ViewModel for clean architecture
- 2. ViewModel holds UI state and business logic; View displays it
- 3. @Observable enables automatic view updates when state changes
- 4. Presentation state (alerts, sheets) belongs in the View, not ViewModel
- 5. ViewModels are testable because they depend on abstractions, not UI
Interview Tips
- • Explain the three MVVM layers and their responsibilities
- • Discuss unidirectional data flow in MVVM with SwiftUI
- • Know why ViewModels are testable (no UI dependencies)
- • Be ready to explain where presentation state vs business state lives
Cheat Sheet
MVVM = Model + View + ViewModel.
ViewModel holds UI state, View observes it.
@Observable (iOS 17+) for observable ViewModels.
ViewModel never references View.
Presentation state stays in View.
ViewModels are testable with mock dependencies.