DEV Community

Sebastien Lato
Sebastien Lato

Posted on

Reusable ViewModel Architecture for SwiftUI

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... } } 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

Provide a real implementation:

final class UserService: UserServiceProtocol { func fetchUser() async throws -> User { // API or local storage } } 
Enter fullscreen mode Exit fullscreen mode

Inject into ViewModel:

@Observable class ProfileViewModel { private let userService: UserServiceProtocol init(userService: UserServiceProtocol) { self.userService = userService } } 
Enter fullscreen mode Exit fullscreen mode

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") } } 
Enter fullscreen mode Exit fullscreen mode

Use in previews:

#Preview { ProfileView(viewModel: .init(userService: MockUserService())) } 
Enter fullscreen mode Exit fullscreen mode

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/ 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

Good ✅

var isValid: Bool { !name.isEmpty && name.count > 3 } 
Enter fullscreen mode Exit fullscreen mode

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 } } 
Enter fullscreen mode Exit fullscreen mode

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() } } 
Enter fullscreen mode Exit fullscreen mode

ViewModel triggers navigation cleanly:

router.push(.settings) 
Enter fullscreen mode Exit fullscreen mode

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? } 
Enter fullscreen mode Exit fullscreen mode

Then:

class ProfileViewModel: BaseViewModel { // profile-specific logic } 
Enter fullscreen mode Exit fullscreen mode

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 } } 
Enter fullscreen mode Exit fullscreen mode

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)