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)