This content originally appeared on DEV Community and was authored by Sebastien Lato
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.
This content originally appeared on DEV Community and was authored by Sebastien Lato
Sebastien Lato | Sciencx (2025-12-01T02:20:48+00:00) Reusable ViewModel Architecture for SwiftUI. Retrieved from https://www.scien.cx/2025/12/01/reusable-viewmodel-architecture-for-swiftui/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.