Free Swiftsonic ticket up for grabs, enter in 10 seconds. Go to the giveaway →
Giveaway: Free Swiftsonic ticket giveaway.
Give your simulator superpowers

RocketSim: An Essential Developer Tool
as recommended by Apple

Subscribe to my YouTube Channel

MVVM in SwiftUI: Using view models without overengineering

MVVM in SwiftUI separates a view’s presentation from the state and actions behind it. A view model exposes the values a view displays and handles work such as loading, validation, or deletion, while the view remains focused on rendering.

SwiftUI already provides powerful tools for state management, so not every view needs a view model. I prefer starting without one and introducing MVVM once a view contains meaningful presentation logic or workflows that I want to test separately.

What is the MVVM pattern?

MVVM is an acronym for Model-View-ViewModel and was originally invented by Microsoft architects. Developers use it to create separation of concerns, ensuring a view is not tied to a specific model type. This results in more reusable code as you can use your view with any model if there’s a view model in-between to provide a communication layer.

Visually, the pattern looks as follows:

MVVM is a common architectural pattern used by developers in SwiftUI.
MVVM is a common architectural pattern used by developers in SwiftUI.

The pattern consists of three layers:

  • Model
    Represents domain data and the operations related to it. For example, a ContactView would have a ContactViewModel that acts as a communication layer with a Contact domain model.
  • View
    Renders state and forwards user interactions. In SwiftUI, this would be your declarative view definition.
  • ViewModel
    Converts model data into presentation state and coordinates actions requested by the view. The view directly binds to properties on the view model to send and receive updates. Since the view model has no reference to the view, it becomes reusable for use with multiple views.

A view model should not reference its SwiftUI view. Instead, the view reads observable state and calls methods representing user actions.

SwiftUI makes this separation less strict than it was in UIKit or AppKit. A view can still use local @State, environment values, and simple model properties directly. MVVM becomes valuable when extracting logic makes the view easier to understand or the behavior easier to test.

Keep your SwiftUI knowledge current

Join 28,912 developers receiving one practical Swift, SwiftUI, Xcode, and AI development update every Tuesday.

One practical email every Tuesday. Unsubscribe anytime.

How to use MVVM in SwiftUI

Imagine a contact screen that allows someone to delete a contact. The domain model remains small:

import Foundation

struct Contact: Equatable {
    let id: UUID
    let name: String
}

Deleting a contact requires access to your data layer. A protocol allows the view model to use the production implementation while tests can provide a lightweight replacement:

@MainActor
protocol ContactRepository {
    func delete(_ contact: Contact) async throws
}

The view model owns the presentation state for the deletion workflow:

import Observation

@Observable
@MainActor
final class ContactViewModel {
    private let contact: Contact
    private let repository: any ContactRepository

    private(set) var isDeleting = false
    private(set) var errorMessage: String?

    var name: String {
        contact.name
    }

    init(
        contact: Contact,
        repository: any ContactRepository
    ) {
        self.contact = contact
        self.repository = repository
    }

    func deleteContact() async {
        isDeleting = true
        errorMessage = nil

        defer {
            isDeleting = false
        }

        do {
            try await repository.delete(contact)
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

The @Observable macro allows SwiftUI to update whenever the view reads a property that changes. Marking the view model with @MainActor ensures that presentation state changes happen on the main actor.

I prefer naming methods after user actions, such as deleteContact(), rather than exposing implementation details to the view. The view does not need to know whether deletion uses a database, network request, or local cache.

Connecting the view model to SwiftUI

The view receives its view model and renders the exposed presentation state:

struct ContactView: View {
    let viewModel: ContactViewModel

    var body: some View {
        VStack {
            Text(viewModel.name)

            Button("Delete") {
                Task {
                    await viewModel.deleteContact()
                }
            }
            .disabled(viewModel.isDeleting)

            if let errorMessage = viewModel.errorMessage {
                Text(errorMessage)
                    .foregroundStyle(.red)
            }
        }
    }
}

A regular stored property is enough for an injected @Observable instance when the view only reads values and calls methods. Use @Bindable when the view needs bindings to properties on the view model.

The view that creates the view model should preserve it using @State. If you still use ObservableObject, the equivalent wrappers are @StateObject for an owned instance and @ObservedObject for an injected instance. My @StateObject vs. @ObservedObject guide explains that ownership difference in detail.

Where should business logic live?

A view model should primarily contain presentation logic. It can transform model data, track loading state, expose validation messages, and coordinate actions requested by the view.

Domain and data-access logic usually belong in a dedicated type. In this example, the repository performs the actual deletion while the view model converts its result into state the view can display.

This separation prevents view models from becoming large objects that know about networking, persistence, analytics, and every other service in your app. You can read more about that separation in my Repository design pattern guide.

Testing a SwiftUI view model

One of the main benefits of MVVM is that you can test presentation logic without rendering the SwiftUI view. A repository mock lets you verify the deletion workflow directly:

import Testing

@MainActor
final class ContactRepositoryMock: ContactRepository {
    private(set) var deletedContacts: [Contact] = []

    func delete(_ contact: Contact) async throws {
        deletedContacts.append(contact)
    }
}

@Test @MainActor
func deletesContact() async {
    let contact = Contact(
        id: UUID(),
        name: "Antoine"
    )
    let repository = ContactRepositoryMock()
    let viewModel = ContactViewModel(
        contact: contact,
        repository: repository
    )

    await viewModel.deleteContact()

    #expect(repository.deletedContacts == [contact])
    #expect(viewModel.isDeleting == false)
    #expect(viewModel.errorMessage == nil)
}

The test focuses on behavior rather than the SwiftUI hierarchy. View-level tests can still verify layout and interaction, but most presentation logic becomes faster to test through the view model.

If you want to learn more about the framework used in this example, read my Swift Testing guide.

Should every SwiftUI view use MVVM?

No. A view model that only copies properties from a model introduces another type without adding meaningful separation.

A simple view can use its input directly:

struct ContactNameView: View {
    let contact: Contact

    var body: some View {
        Text(contact.name)
    }
}

Introducing a ContactNameViewModel for this example would make the code harder to navigate without improving testability.

I consider adding a view model when a view:

  • Coordinates an asynchronous workflow.
  • Transforms domain data into presentation state.
  • Handles validation or user-facing errors.
  • Contains behavior worth testing separately.
  • Depends on several services or repositories.

Consistency remains important, but consistency does not require every view to have a view model. Small presentation-only views can stay simple while larger screens use MVVM where it provides clear value.

Conclusion

MVVM in SwiftUI separates presentation state and actions from the view that renders them. Modern view models can use @Observable to communicate state changes, while injected repositories keep domain and data-access logic in dedicated types.

You do not have to introduce a view model for every SwiftUI view. Start with the view itself and extract a view model once doing so improves readability, testability, or state management. That approach gives you the benefits of MVVM without unnecessary abstraction.

If you want to improve your SwiftUI knowledge, even more, check out the SwiftUI category page. Feel free to contact me or tweet me on Twitter if you have any additional tips or feedback.

Thanks!

 
Antoine van der Lee

Written by

Antoine van der Lee

iOS Developer since 2010, former Staff iOS Engineer at WeTransfer and currently full-time Indie Developer & Founder at SwiftLee. Writing a new blog post every week related to Swift, iOS and Xcode. Regular speaker and workshop host.

Are you ready to

Turn your side projects into independence?

Learn my proven steps to transform your passion into profit.