Building scalable iOS apps requires a solid architecture. MVVM (Model-View-ViewModel) has become the go-to pattern for SwiftUI developers. In this guide, I'll show you how to implement MVVM properly with practical examples.
Why MVVM for SwiftUI?
SwiftUI's declarative nature pairs perfectly with MVVM:
- Separation of concerns: UI logic stays in Views, business logic in ViewModels
- Testability: ViewModels can be unit tested without UI
- Reusability: ViewModels can be shared across different views
- Maintainability: Changes in one layer don't affect others
The Three Layers
1. Model
The Model represents your data and business logic:
struct User: Identifiable, Codable {
let id: UUID
var name: String
var email: String
var isActive: Bool
}
2. ViewModel
The ViewModel acts as a bridge between Model and View:
import SwiftUI
@Observable
class UserViewModel {
private(set) var users: [User] = []
private(set) var isLoading = false
private(set) var errorMessage: String?
private let networkService: NetworkService
init(networkService: NetworkService = .shared) {
self.networkService = networkService
}
func fetchUsers() async {
isLoading = true
errorMessage = nil
do {
users = try await networkService.fetch(endpoint: "/users")
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
func toggleUserStatus(_ user: User) {
guard let index = users.firstIndex(where: { $0.id == user.id }) else { return }
users[index].isActive.toggle()
}
}
3. View
The View displays data and sends user actions to ViewModel:
struct UsersListView: View {
@State private var viewModel = UserViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView("Loading...")
} else if let error = viewModel.errorMessage {
ContentUnavailableView(
"Error",
systemImage: "exclamationmark.triangle",
description: Text(error)
)
} else {
usersList
}
}
.navigationTitle("Users")
.task {
await viewModel.fetchUsers()
}
}
}
private var usersList: some View {
List(viewModel.users) { user in
UserRowView(user: user) {
viewModel.toggleUserStatus(user)
}
}
}
}
Best Practices
1. Use @observable (iOS 17+)
The new @Observable macro simplifies state management:
@Observable
class ViewModel {
var data: [Item] = [] // Automatically tracked
}
2. Dependency Injection
Make your ViewModels testable:
class ProductViewModel {
private let repository: ProductRepositoryProtocol
init(repository: ProductRepositoryProtocol = ProductRepository()) {
self.repository = repository
}
}
3. Protocol-Oriented Design
Define protocols for your services:
protocol NetworkServiceProtocol {
func fetch<T: Decodable>(endpoint: String) async throws -> T
}
class NetworkService: NetworkServiceProtocol {
static let shared = NetworkService()
func fetch<T: Decodable>(endpoint: String) async throws -> T {
// Implementation
}
}
4. Error Handling
Always handle errors gracefully:
enum AppError: LocalizedError {
case networkError
case decodingError
case unauthorized
var errorDescription: String? {
switch self {
case .networkError: return "Network connection failed"
case .decodingError: return "Failed to process data"
case .unauthorized: return "Please log in again"
}
}
}
Project Structure
MyApp/
├── Models/
│ ├── User.swift
│ └── Product.swift
├── ViewModels/
│ ├── UserViewModel.swift
│ └── ProductViewModel.swift
├── Views/
│ ├── UsersListView.swift
│ └── ProductDetailView.swift
├── Services/
│ ├── NetworkService.swift
│ └── StorageService.swift
└── Utilities/
└── Extensions.swift
Testing Your ViewModel
final class UserViewModelTests: XCTestCase {
var sut: UserViewModel!
var mockService: MockNetworkService!
override func setUp() {
mockService = MockNetworkService()
sut = UserViewModel(networkService: mockService)
}
func testFetchUsersSuccess() async {
mockService.mockUsers = [User(id: UUID(), name: "Test", email: "test@test.com", isActive: true)]
await sut.fetchUsers()
XCTAssertEqual(sut.users.count, 1)
XCTAssertNil(sut.errorMessage)
}
}
Conclusion
MVVM with SwiftUI creates clean, testable, and maintainable code. The key principles:
- Keep Views dumb - they only display data
- ViewModels handle all business logic
- Use protocols for dependency injection
- Write tests for your ViewModels
Want a complete starter project with MVVM already set up?
I've created SwiftUI Starter Kit Pro - a production-ready template with:
- 5 screens (Onboarding, Auth, Home, Profile, Settings)
- MVVM architecture implemented
- NetworkManager & StorageManager included
- 20+ reusable UI components
- Dark mode support
Save 40+ hours of setup time and start building your app today!
Top comments (0)