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:

The pattern consists of three layers:
- Model
Represents domain data and the operations related to it. For example, aContactViewwould have aContactViewModelthat acts as a communication layer with aContactdomain 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.
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!