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...
    }
}

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)