Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 032 / 100

MVVM in SwiftUI: Swift & iOS best practices explained

Organize screens for testability.

Expona Admin9 min read

MVVM in SwiftUI: Swift & iOS best practices explained. Organize screens for testability.

Introduction

By day 32 of building iOS apps, you've likely hit a wall where your Views contain too much logic, making them hard to test and reuse. MVVM in SwiftUI is the architectural pattern that separates presentation from business logic, giving you screens that are testable, maintainable, and predictable. This guide walks through the practical implementation of MVVM in SwiftUI with real examples from production apps.

Why MVVM matters in SwiftUI

SwiftUI makes it easy to bind state directly to Views, but that convenience can become a trap. When a View holds networking calls, data persistence, and validation logic, testing becomes painful—you can't instantiate the View without mocking half your app. MVVM in SwiftUI separates concerns cleanly: the View only displays state and sends user actions, while the ViewModel orchestrates logic and talks to services.

The architecture also scales. A simple toggle doesn't need a ViewModel. A product detail screen with filtering, favorites, reviews, and ratings does. MVVM lets you grow incrementally without refactoring your entire app.

View — Displays state and sends user actions

In MVVM, the View is a pure presentation layer. It reads state from the ViewModel, displays it, and sends user actions back. The View never calls a network endpoint, never writes to disk, and never knows about service dependencies.

Here's a practical example: a product list screen that loads items from an API.

swift
struct ProductListView: View {
    @State var viewModel: ProductListViewModel
    
    var body: some View {
        NavigationStack {
            ZStack {
                List(viewModel.products) { product in
                    NavigationLink(value: product.id) {
                        VStack(alignment: .leading) {
                            Text(product.name)
                                .font(.headline)
                            Text("$\(product.price, format: .currency(code: "USD"))")
                                .font(.caption)
                                .foregroundColor(.secondary)
                        }
                    }
                }
                
                if viewModel.isLoading {
                    ProgressView()
                }
            }
            .alert("Error", isPresented: .constant(viewModel.error != nil)) {
                Button("Retry") {
                    viewModel.reload()
                }
            } message: {
                Text(viewModel.error?.localizedDescription ?? "")
            }
            .onAppear {
                viewModel.loadProducts()
            }
            .navigationTitle("Products")
            .navigationDestination(for: Int.self) { productId in
                ProductDetailView(productId: productId)
            }
        }
    }
}

Notice what the View doesn't do: it doesn't construct URLSession, doesn't parse JSON, doesn't manage retry logic. It only reads properties and calls ViewModel methods. The View is a passive component.

ViewModel — @Observable class with state and logic

The ViewModel holds all the mutable state and business logic for a screen. In modern SwiftUI (iOS 17+), you use the @Observable macro instead of ObservableObject. It's cleaner and doesn't require @Published annotations.

Here's the ViewModel for the product list:

swift
@Observable
final class ProductListViewModel {
    var products: [Product] = []
    var isLoading = false
    var error: Error? = nil
    
    private let productService: ProductServiceProtocol
    
    init(productService: ProductServiceProtocol = ProductService.shared) {
        self.productService = productService
    }
    
    func loadProducts() async {
        isLoading = true
        error = nil
        
        do {
            products = try await productService.fetchProducts()
            isLoading = false
        } catch {
            self.error = error
            isLoading = false
        }
    }
    
    func reload() {
        Task {
            await loadProducts()
        }
    }
}

Key principles here:

  • Dependencies injected via init. The ViewModel receives a protocol-based service, not a concrete implementation. This makes testing trivial—you inject a mock.
  • State is mutable. The ViewModel mutates its own properties; SwiftUI bindings flow from state changes.
  • Logic is synchronous where possible. We use async/await for long-running operations, but the ViewModel itself is not async.
  • Error handling is explicit. State includes an error property that the View can bind to.

When you need to test this ViewModel, you create a mock service and verify behavior without touching the network:

swift
class MockProductService: ProductServiceProtocol {
    var shouldFail = false
    
    func fetchProducts() async throws -> [Product] {
        if shouldFail {
            throw URLError(.networkConnectionLost)
        }
        return [
            Product(id: 1, name: "Widget", price: 19.99),
            Product(id: 2, name: "Gadget", price: 29.99)
        ]
    }
}

@Test
func testLoadProductsSuccess() async {
    let mockService = MockProductService()
    let viewModel = ProductListViewModel(productService: mockService)
    
    await viewModel.loadProducts()
    
    #expect(viewModel.products.count == 2)
    #expect(viewModel.error == nil)
    #expect(!viewModel.isLoading)
}

This test runs in milliseconds with zero network calls. That's the power of MVVM.

Services — Networking and persistence behind protocols

Services handle I/O: network requests, database reads, file storage. They're always protocol-based so the ViewModel doesn't depend on concrete implementations.

Define the protocol first:

swift
protocol ProductServiceProtocol {
    func fetchProducts() async throws -> [Product]
    func fetchProduct(id: Int) async throws -> Product
    func updateProduct(_ product: Product) async throws
}

protocol PersistenceServiceProtocol {
    func saveFavorite(_ productId: Int) async throws
    func removeFavorite(_ productId: Int) async throws
    func isFavorited(_ productId: Int) async -> Bool
}

Then implement the concrete service:

swift
final class ProductService: ProductServiceProtocol {
    static let shared = ProductService()
    
    private let session: URLSession
    private let baseURL: URL
    
    init(
        session: URLSession = .shared,
        baseURL: URL = URL(string: "https://api.example.com")!
    ) {
        self.session = session
        self.baseURL = baseURL
    }
    
    func fetchProducts() async throws -> [Product] {
        let url = baseURL.appending(path: "/products")
        let (data, _) = try await session.data(from: url)
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        return try decoder.decode([Product].self, from: data)
    }
    
    func fetchProduct(id: Int) async throws -> Product {
        let url = baseURL.appending(path: "/products/\(id)")
        let (data, _) = try await session.data(from: url)
        return try JSONDecoder().decode(Product.self, from: data)
    }
    
    func updateProduct(_ product: Product) async throws {
        let url = baseURL.appending(path: "/products/\(product.id)")
        var request = URLRequest(url: url)
        request.httpMethod = "PUT"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONEncoder().encode(product)
        
        let (_, response) = try await session.data(for: request)
        guard (response as? HTTPURLResponse)?.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }
    }
}

Notice the constructor accepts dependencies (URLSession, baseURL). In tests or for different API environments, you swap implementations without changing the ViewModel.

The persistence service might use SwiftData or CoreData:

swift
final class PersistenceService: PersistenceServiceProtocol {
    private let modelContext: ModelContext
    
    init(modelContext: ModelContext) {
        self.modelContext = modelContext
    }
    
    func saveFavorite(_ productId: Int) async throws {
        let favorite = Favorite(productId: productId, savedAt: .now)
        modelContext.insert(favorite)
        try modelContext.save()
    }
    
    func isFavorited(_ productId: Int) async -> Bool {
        let predicate = #Predicate { $0.productId == productId }
        let descriptor = FetchDescriptor(predicate: predicate)
        return (try? modelContext.fetchCount(descriptor)) ?? 0 > 0
    }
}

Keep it light — Simple screens do not need a ViewModel

Not every screen needs a ViewModel. A simple settings toggle, a static detail view, or a confirmation dialog can live entirely in the View with a local @State variable. Adding a ViewModel to everything creates boilerplate that slows you down.

Use this heuristic: if the View only manages UI state (like a sheet being open) and doesn't touch services, skip the ViewModel.

Example of a screen that doesn't need a ViewModel:

swift
struct SettingsView: View {
    @AppStorage("theme") var theme: String = "light"
    @State private var showConfirmation = false
    
    var body: some View {
        Form {
            Picker("Theme", selection: $theme) {
                Text("Light").tag("light")
                Text("Dark").tag("dark")
            }
            
            Button(role: .destructive) {
                showConfirmation = true
            } label: {
                Text("Clear Cache")
            }
            .confirmationDialog(
                "Clear cache?",
                isPresented: $showConfirmation
            ) {
                Button("Clear", role: .destructive) {
                    URLCache.shared.removeAllCachedResponses()
                }
            }
        }
    }
}

This View manages its own state perfectly fine. The business logic (app storage, cache clearing) is minimal and tightly coupled to the UI. A ViewModel here is over-engineering.

But if that settings screen had to sync preferences to the backend, validate against business rules, or fetch user profile data, suddenly a ViewModel makes sense.

Common mistakes

Mistake 1: ViewModel as a data bag. ViewModels that only hold state with no logic are just glorified data containers. If your ViewModel is just var name: String and nothing else, the View should own that state.

Mistake 2: Services in ViewModels without protocols. When you inject a concrete service class directly into the ViewModel, you lose the ability to mock. Always use protocols. It costs nothing and gives you test coverage.

Mistake 3: Logic in the View. Putting date formatting, filtering, or calculation logic in the View makes it fat and untestable. Move that to the ViewModel or a helper function.

Mistake 4: Ignoring memory cycles. When a ViewModel holds a strong reference to a service that holds a strong reference back to the ViewModel (via a closure or callback), you leak memory. Use weak self in closures or, better yet, design services to not hold ViewModel references.

Mistake 5: Async/await in init. Never await in a ViewModel initializer. Load data in a method you call after init, triggered by .onAppear or similar.

Key takeaways

  • MVVM separates presentation (View), logic (ViewModel), and I/O (Services) so each layer is testable independently.
  • Views are passive: they read state and call ViewModel methods. They never touch services or business logic directly.
  • ViewModels are observable classes that orchestrate state and delegate I/O to protocol-based services.
  • Services are behind protocols so you can mock them in tests without touching the network or disk.
  • Simple screens don't need ViewModels—use local @State for UI-only concerns.
  • Test the ViewModel by injecting mock services; test services independently with real I/O or stubbed responses.
  • Use @Observable in iOS 17+ instead of ObservableObject for cleaner, less boilerplate code.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles