Introducing MVVM into your SwiftUI project with best practices - Featured Image
App Development7 min read

Introducing MVVM into your SwiftUI project with best practices

Building SwiftUI apps can quickly become overwhelming when all your logic lives inside your views. As your project grows, you'll find yourself with massive view files that handle everything from data management to user interactions. This is where MVVM (Model-View-ViewModel) comes to the rescue.

Understanding the problem first

Before jumping into MVVM, let's understand what we're trying to solve. Here's a typical SwiftUI view that's doing too much:

struct ContentView: View {
    @State private var locations = [Location]()
    @State private var selectedPlace: Location?
    @State private var showingAlert = false
    
    var body: some View {
        Map(coordinateRegion: .constant(region)) {
            ForEach(locations, id: \.id) { location in
                // Map annotations
            }
        }
        .onTapGesture { position in
            // Adding new location logic here
            let newLocation = Location(...)
            locations.append(newLocation)
            saveData() // Saving logic mixed in
        }
        .sheet(item: $selectedPlace) { place in
            EditView(location: place) { updatedLocation in
                // Update logic here
                if let index = locations.firstIndex(of: place) {
                    locations[index] = updatedLocation
                    saveData() // More saving logic
                }
            }
        }
    }
    
    private func saveData() {
        // Complex saving logic here
    }
}

This view is handling presentation, data management, and business logic all at once. That's a recipe for messy, hard-to-test code.

What is MVVM?

MVVM stands for Model-View-ViewModel. Think of it as a way to organize your code into three distinct layers:

  • Model: Your data structures (Location, User, etc.)

  • View: Your SwiftUI views that handle presentation

  • ViewModel: The bridge between your model and view that handles business logic

The beauty of MVVM is that each layer has a single responsibility, making your code more maintainable and testable.

Step 1: Creating your first view model

Let's start by creating a proper home for our view model. Create a new file called ContentView-ViewModel.swift:

import Foundation
import MapKit

extension ContentView {
    @Observable
    class ViewModel {
        // Our data will live here
    }
}

Best Practice: Always place your view model inside an extension of its corresponding view. This creates a clear relationship and keeps your naming simple - you can always refer to it as just ViewModel.

Step 2: Moving state to the view model

Now let's move our state from the view to the view model. This is where the magic begins:

extension ContentView {
    @Observable
    class ViewModel {
        var locations = [Location]()
        var selectedPlace: Location?
    }
}

In your view, replace all those @State properties with a single view model instance:

struct ContentView: View {
    @State private var viewModel = ViewModel()
    
    var body: some View {
        // Now use viewModel.locations instead of locations
        Map(coordinateRegion: .constant(region)) {
            ForEach(viewModel.locations, id: \.id) { location in
                // Map annotations
            }
        }
    }
}

Best Practice: Start by moving obvious data properties first. Don't try to move everything at once - take it step by step.

Step 3: Implementing data protection

Here's where we add our first layer of protection. Make your data properties read-only from the outside:

extension ContentView {
    @Observable
    class ViewModel {
        private(set) var locations = [Location]()
        private(set) var selectedPlace: Location?
    }
}

Now only the view model can modify these properties. This will immediately show you where you need to create proper methods instead of direct data manipulation.

Best Practice: Use private(set) for any data that should only be modified through specific methods. This enforces proper encapsulation.

Step 4: Adding business logic methods

Instead of manipulating data directly in your view, create dedicated methods in your view model:

extension ContentView {
    @Observable
    class ViewModel {
        private(set) var locations = [Location]()
        private(set) var selectedPlace: Location?
        
        func addLocation(at coordinate: CLLocationCoordinate2D) {
            let newLocation = Location(
                id: UUID(),
                name: "New location",
                description: "",
                latitude: coordinate.latitude,
                longitude: coordinate.longitude
            )
            locations.append(newLocation)
        }
        
        func updateLocation(_ updatedLocation: Location) {
            guard let selectedPlace = selectedPlace else { return }
            
            if let index = locations.firstIndex(of: selectedPlace) {
                locations[index] = updatedLocation
            }
        }
        
        func selectPlace(_ place: Location) {
            selectedPlace = place
        }
    }
}

Best Practice: Each method should have a single, clear purpose. Name your methods based on what they do, not how they do it.

Step 5: Implementing data persistence

Now let's add professional-level data persistence to our view model:

extension ContentView {
    @Observable
    class ViewModel {
        private(set) var locations = [Location]()
        private(set) var selectedPlace: Location?
        
        private let savePath = URL.documentsDirectory.appending(path: "SavedPlaces")
        
        init() {
            loadData()
        }
        
        func addLocation(at coordinate: CLLocationCoordinate2D) {
            let newLocation = Location(
                id: UUID(),
                name: "New location",
                description: "",
                latitude: coordinate.latitude,
                longitude: coordinate.longitude
            )
            locations.append(newLocation)
            saveData()
        }
        
        func updateLocation(_ updatedLocation: Location) {
            guard let selectedPlace = selectedPlace else { return }
            
            if let index = locations.firstIndex(of: selectedPlace) {
                locations[index] = updatedLocation
                saveData()
            }
        }
        
        private func loadData() {
            do {
                let data = try Data(contentsOf: savePath)
                locations = try JSONDecoder().decode([Location].self, from: data)
            } catch {
                // Start with empty array if loading fails
                locations = []
            }
        }
        
        private func saveData() {
            do {
                let data = try JSONEncoder().encode(locations)
                try data.write(to: savePath, options: [.atomic, .completeFileProtection])
            } catch {
                print("Failed to save data: \(error)")
            }
        }
    }
}

Best Practice: Always save data automatically after modifications. Users shouldn't have to remember to save manually. The .completeFileProtection option ensures your data is encrypted.

Step 6: Cleaning up your view

With the view model handling all the heavy lifting, your view becomes beautifully simple:

struct ContentView: View {
    @State private var viewModel = ViewModel()
    
    var body: some View {
        Map(coordinateRegion: .constant(region)) {
            ForEach(viewModel.locations, id: \.id) { location in
                MapAnnotation(coordinate: location.coordinate) {
                    // Your annotation view
                }
            }
        }
        .onTapGesture { position in
            if let coordinate = proxy.convert(position, from: .local) {
                viewModel.addLocation(at: coordinate)
            }
        }
        .sheet(item: $viewModel.selectedPlace) { place in
            EditView(location: place) { updatedLocation in
                viewModel.updateLocation(updatedLocation)
            }
        }
    }
}

Best Practice: Your view should only handle presentation and user interactions. All business logic should live in the view model.

Step 7: Error handling and validation

Professional apps need proper error handling. Let's add that to our view model:

extension ContentView {
    @Observable
    class ViewModel {
        private(set) var locations = [Location]()
        private(set) var selectedPlace: Location?
        private(set) var errorMessage: String?
        
        func addLocation(at coordinate: CLLocationCoordinate2D) {
            // Validation
            guard coordinate.latitude >= -90 && coordinate.latitude <= 90 else {
                errorMessage = "Invalid latitude"
                return
            }
            
            let newLocation = Location(
                id: UUID(),
                name: "New location",
                description: "",
                latitude: coordinate.latitude,
                longitude: coordinate.longitude
            )
            
            locations.append(newLocation)
            saveData()
            clearError()
        }
        
        private func clearError() {
            errorMessage = nil
        }
    }
}

Best Practice: Always validate input and provide meaningful error messages. Make error states observable so your view can respond appropriately.

Advanced techniques

Once you're comfortable with basic MVVM implementation, these advanced patterns will help you build more robust and maintainable applications.

Dependency injection

For better testing, inject dependencies rather than creating them directly:

extension ContentView {
    @Observable
    class ViewModel {
        private let dataManager: DataManager
        
        init(dataManager: DataManager = DataManager()) {
            self.dataManager = dataManager
        }
    }
}

Async operations

Handle async operations properly:

func loadLocationsFromAPI() async {
    do {
        let fetchedLocations = try await apiService.fetchLocations()
        await MainActor.run {
            self.locations = fetchedLocations
        }
    } catch {
        await MainActor.run {
            self.errorMessage = "Failed to load locations"
        }
    }
}

Common mistakes to avoid

  1. Don't put view logic in your view model - Keep UI-specific code in the view

  2. Don't make everything observable - Only make properties observable that the view needs to react to

  3. Don't skip error handling - Always handle potential failures gracefully

  4. Don't create massive view models - Split large view models into smaller, focused ones

Testing your view model

One of the biggest benefits of MVVM is testability. Here's how to test your view model:

class ViewModelTests: XCTestCase {
    func testAddLocation() {
        let viewModel = ContentView.ViewModel()
        let coordinate = CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060)
        
        viewModel.addLocation(at: coordinate)
        
        XCTAssertEqual(viewModel.locations.count, 1)
        XCTAssertEqual(viewModel.locations.first?.latitude, 40.7128)
    }
}

When to use MVVM

MVVM is perfect when you:

  • Have complex business logic

  • Need to test your code

  • Want to separate concerns clearly

  • Plan to reuse logic across different views

Avoid MVVM when:

  • Building very simple views

  • Using SwiftData (compatibility issues)

  • Prototyping quickly

Conclusion

MVVM transforms your SwiftUI development by creating clear boundaries between your data, business logic, and presentation layers. By following these best practices - starting with simple state management, adding proper data protection, implementing clean business logic methods, and handling errors gracefully - you'll create apps that are not only more maintainable but also more professional. The key to success with MVVM is gradual adoption. Start with one view, move its logic to a view model, and see how much cleaner your code becomes. Once you experience the benefits of separated concerns and improved testability, you'll wonder how you ever built apps without it.

Posted on: 16/7/2025

hassaankhan

Frontend Developer — UI/UX Enthusiast and building scalable web apps

Posted by





Subscribe to our newsletter

Join 2,000+ subscribers

Stay in the loop with everything you need to know.

We care about your data in our privacy policy

Background shadow leftBackground shadow right

Have something to share?

Write on the platform and dummy copy content

Be Part of Something Big

Shifters, a developer-first community platform, is launching soon with all the features. Don't miss out on day one access. Join the waitlist: