SwiftUI has evolved fast — especially with the new @observable macro, improved data flow, and better navigation tools.
But one thing still confuses many developers:
How do you structure ViewModels in a scalable, reusable, testable way?
This post shows the best-practice ViewModel architecture I now use in all my apps — simple enough for small projects, clean enough for large ones.
You’ll learn:
- how to design clean ViewModels
- how to separate logic from UI
- how to use @observable correctly
- how to inject services
- how to mock data for testing
- how to structure features
- how to avoid “massive ViewModels”
Let’s build it the right way. 🚀
🧠 1. The Role of a ViewModel
A ViewModel should:
- hold UI state
- expose derived values
- coordinate services
- validate user input
- trigger navigation actions
- manage async work
A ViewModel should NOT:
- contain UI layout logic
- directly manipulate views
- know about modifiers
- be tightly coupled to other screens
Keep it clean, testable, and reusable.
🎯 2. Using the @observable Macro Correctly
This is the new recommended pattern:
@Observable
class ProfileViewModel {
var name: String = ""
var isLoading: Bool = false
func loadProfile() async {
isLoading = true
defer { isLoading = false }
// fetch...
}
}
Benefits:
- automatic change tracking
- no need for @Published
- works perfectly with NavigationStack
- lightweight and simple
🔌 3. Service Injection (Clean & Testable)
Define service protocols:
protocol UserServiceProtocol {
func fetchUser() async throws -> User
}
Provide a real implementation:
final class UserService: UserServiceProtocol {
func fetchUser() async throws -> User {
// API or local storage
}
}
Inject into ViewModel:
@Observable
class ProfileViewModel {
private let userService: UserServiceProtocol
init(userService: UserServiceProtocol) {
self.userService = userService
}
}
This makes your screens fully testable.
🧪 4. Mocks for Testing (Huge Productivity Boost)
struct MockUserService: UserServiceProtocol {
func fetchUser() async throws -> User {
.init(id: 1, name: "Test User")
}
}
Use in previews:
#Preview {
ProfileView(viewModel: .init(userService: MockUserService()))
}
Zero API calls. Instant UI previews. Perfect for rapid design.
🧱 5. The Clean Feature Folder Structure
Features/
│
├── Profile/
│ ├── ProfileView.swift
│ ├── ProfileViewModel.swift
│ ├── ProfileService.swift
│ ├── ProfileModels.swift
│ └── ProfileTests.swift
│
├── Settings/
├── Home/
├── Explore/
Each feature contains:
- View
- ViewModel
- Models
- Services
- Tests
Self-contained. Easy to move, refactor, or delete.
⚡ 6. State Derivation (Computed Logic, Not Stored State)
Avoid storing unnecessary state.
Bad ❌
var isValid: Bool = false
Good ✅
var isValid: Bool {
!name.isEmpty && name.count > 3
}
Derived state should not be stored.
🔄 7. Async Patterns Without Messy State
Clean async loading:
func load() async {
isLoading = true
defer { isLoading = false }
do {
user = try await userService.fetchUser()
} catch {
errorMessage = error.localizedDescription
}
}
This avoids callback hell and race conditions.
🧭 8. ViewModels + Navigation
Inject navigation through a Router:
@Observable
class Router {
var path = NavigationPath()
func push(_ route: Route) { path.append(route) }
func pop() { path.removeLast() }
}
ViewModel triggers navigation cleanly:
router.push(.settings)
No view-side hacks. Pure MVVM.
♻️ 9. Reusable BaseViewModel (Optional but Powerful)
If several screens share loading/error patterns:
@Observable
class BaseViewModel {
var isLoading = false
var errorMessage: String?
}
Then:
class ProfileViewModel: BaseViewModel {
// profile-specific logic
}
Keeps ViewModels DRY.
🧩 10. Example of a Real Production ViewModel
@Observable
class ProfileViewModel {
var user: User?
var isLoading = false
var errorMessage: String?
private let userService: UserServiceProtocol
init(userService: UserServiceProtocol) {
self.userService = userService
}
@MainActor
func load() async {
isLoading = true
defer { isLoading = false }
do {
user = try await userService.fetchUser()
} catch {
errorMessage = error.localizedDescription
}
}
var initials: String {
String(user?.name.prefix(1) ?? "?")
}
var hasProfile: Bool {
user != nil
}
}
This is real-world, clean, testable Swift.
🚀 Final Thoughts
A solid ViewModel architecture gives you:
- predictable state
- clean separation of concerns
- massively easier testing
- reusable features
- smoother scaling
- easier onboarding for collaborators
Now you have all the patterns to build scalable SwiftUI features with modern best practices.
Top comments (0)