A Practical Guide to MVVM in SwiftUI

MVVM is the go-to architecture for SwiftUI apps. Here's a no-nonsense guide with real code examples.

Every SwiftUI tutorial eventually tells you to “use MVVM.” Most of them then proceed to show you a todo list that nobody would actually build that way. Let me try something different: a straightforward explanation of why MVVM matters, when it helps, and how to actually implement it.

What MVVM Actually Is

MVVM stands for Model-View-ViewModel. Three layers, three responsibilities:

  • Model: Your data. Structs, maybe some validation logic. No UI awareness.
  • View: Your SwiftUI views. Displays things, sends user actions somewhere else.
  • ViewModel: The glue. Takes models, transforms them for display. Handles user actions, talks to services.

The key insight is that your View shouldn’t know how data is fetched, validated, or stored. It just knows what to display and what to do when buttons get tapped.

Why Bother?

You could throw everything into your SwiftUI views. For a two-screen app, that’s probably fine. But the moment your app grows—and they always grow—you’ll hit problems:

  1. Testing becomes painful. UI tests are slow and flaky. Unit tests for ViewModels are fast and reliable.
  2. Views get bloated. A 500-line view mixing layout, business logic, and network calls is nobody’s idea of fun.
  3. Reuse gets hard. That nice data transformation logic buried in one view? Good luck using it elsewhere.

MVVM isn’t magic. It’s just about putting code where it belongs.

The Building Blocks

Let’s build something real—a simple contacts list. Nothing fancy, but enough to see the pattern.

The Model

Start simple. A model should be a dumb data container:

struct Contact: Identifiable {
    let id: UUID
    var name: String
    var email: String
    var isFavorite: Bool
}

That’s it. No @Published, no ObservableObject. Just data.

The ViewModel

Here’s where things get interesting. The ViewModel needs to:

  • Hold the current state
  • Expose it to the View
  • Handle actions from the View
import SwiftUI

@Observable
class ContactListViewModel {
    var contacts: [Contact] = []
    var isLoading = false
    var errorMessage: String?

    private let contactService: ContactService

    init(contactService: ContactService = ContactService()) {
        self.contactService = contactService
    }

    func loadContacts() async {
        isLoading = true
        errorMessage = nil

        do {
            contacts = try await contactService.fetchContacts()
        } catch {
            errorMessage = "Failed to load contacts"
        }

        isLoading = false
    }

    func toggleFavorite(for contact: Contact) {
        guard let index = contacts.firstIndex(where: { $0.id == contact.id }) else { return }
        contacts[index].isFavorite.toggle()
    }

    func deleteContact(_ contact: Contact) {
        contacts.removeAll { $0.id == contact.id }
    }
}

Notice what’s happening here. The ViewModel owns the state (contacts, isLoading, errorMessage). It exposes methods for actions (loadContacts, toggleFavorite, deleteContact). The View will call these—it doesn’t need to know anything about ContactService or how favorites work internally.

I’m using @Observable here, which is the modern way to do this in iOS 17+. If you’re stuck on earlier versions, swap it for ObservableObject with @Published properties. The concept is identical.

The View

Now the View becomes almost boring—in a good way:

struct ContactListView: View {
    @State private var viewModel = ContactListViewModel()

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView("Loading...")
                } else if let error = viewModel.errorMessage {
                    ContentUnavailableView(
                        "Something went wrong",
                        systemImage: "exclamationmark.triangle",
                        description: Text(error)
                    )
                } else {
                    contactList
                }
            }
            .navigationTitle("Contacts")
            .task {
                await viewModel.loadContacts()
            }
        }
    }

    private var contactList: some View {
        List {
            ForEach(viewModel.contacts) { contact in
                ContactRow(
                    contact: contact,
                    onToggleFavorite: { viewModel.toggleFavorite(for: contact) }
                )
            }
            .onDelete { indexSet in
                for index in indexSet {
                    viewModel.deleteContact(viewModel.contacts[index])
                }
            }
        }
    }
}

struct ContactRow: View {
    let contact: Contact
    let onToggleFavorite: () -> Void

    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(contact.name)
                    .font(.headline)
                Text(contact.email)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }

            Spacer()

            Button(action: onToggleFavorite) {
                Image(systemName: contact.isFavorite ? "star.fill" : "star")
                    .foregroundStyle(contact.isFavorite ? .yellow : .gray)
            }
            .buttonStyle(.plain)
        }
    }
}

The View is all about layout and user interaction. When the user taps the star, it calls onToggleFavorite. That’s a closure passed from the parent—the row doesn’t care what happens, just that something should happen.

The Old Way (ObservableObject)

If you’re targeting iOS 16 or earlier, the pattern looks slightly different:

class ContactListViewModel: ObservableObject {
    @Published var contacts: [Contact] = []
    @Published var isLoading = false
    @Published var errorMessage: String?

    // ... rest is the same
}

struct ContactListView: View {
    @StateObject private var viewModel = ContactListViewModel()

    // ... rest is the same
}

The main differences:

  • ObservableObject instead of @Observable
  • @Published on each property you want to trigger updates
  • @StateObject instead of @State to create the ViewModel

Both approaches work. @Observable is just cleaner.

Dependency Injection

Notice how the ViewModel takes a ContactService in its initializer? That’s dependency injection, and it’s what makes testing possible:

class ContactListViewModel {
    private let contactService: ContactService

    init(contactService: ContactService = ContactService()) {
        self.contactService = contactService
    }
}

The default parameter means normal usage is simple—ContactListViewModel() just works. But in tests, you can inject a mock:

func testLoadContactsSuccess() async {
    let mockService = MockContactService(
        contacts: [Contact(id: UUID(), name: "Test", email: "test@example.com", isFavorite: false)]
    )
    let viewModel = ContactListViewModel(contactService: mockService)

    await viewModel.loadContacts()

    XCTAssertEqual(viewModel.contacts.count, 1)
    XCTAssertFalse(viewModel.isLoading)
    XCTAssertNil(viewModel.errorMessage)
}

No UI needed. No simulators. Just fast, reliable unit tests.

When MVVM Isn’t Worth It

I said I’d be honest, so here it is: MVVM adds indirection. For genuinely simple screens—a static about page, a settings toggle or two—you might not need it. Shoving everything through a ViewModel for the sake of architecture is its own kind of mess.

My rule of thumb: if a view has state that changes, logic that decides things, or data that comes from elsewhere, give it a ViewModel. If it’s just laying out static content, don’t bother.

Wrapping Up

MVVM isn’t complicated once you see the pattern:

  1. Models hold data
  2. ViewModels hold state and logic, exposed through properties and methods
  3. Views display stuff and forward user actions

The boundaries keep your code testable, maintainable, and—dare I say—pleasant to work with. It’s not the only architecture out there, and it won’t solve every problem. But for most SwiftUI apps, it’s a solid foundation.

Start simple, add structure when you feel the pain, and remember: architecture exists to make your life easier, not to impress anyone.