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
Don't put view logic in your view model - Keep UI-specific code in the view
Don't make everything observable - Only make properties observable that the view needs to react to
Don't skip error handling - Always handle potential failures gracefully
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.