Hannes Dermutz
Back to Home

Why I Am Still Using MVVM in SwiftUI

SwiftUIMVVMArchitectureiOS

Every few months, the same discussion comes back: does SwiftUI still need MVVM?

The honest answer is no. SwiftUI does not need MVVM. The framework already gives us a declarative rendering model, local state, bindings, environment values, observation, and async tasks directly inside views. For many screens, that is enough. A simple settings toggle, a static detail view, or a small form should not be wrapped in architecture just because we are used to doing it.

But that is not the same as saying MVVM is obsolete.

I still use MVVM in SwiftUI because real apps are rarely just views. Real apps load data, validate input, coordinate navigation, manage permissions, recover from errors, cache results, sync with APIs, handle offline states, and expose business rules that must survive redesigns. When that logic lives directly inside a SwiftUI view, the view stops being a description of UI and starts becoming the screen’s control center.

That is where MVVM still earns its place.

SwiftUI views should stay easy to delete

One of my favorite tests for SwiftUI architecture is simple: how painful would it be to redesign this screen?

If changing the layout means carefully moving networking calls, validation branches, analytics events, sorting logic, permission checks, and state transitions between VStacks and task modifiers, the view is doing too much.

SwiftUI views are cheap. They are meant to change. Product requirements change, visual hierarchy changes, navigation changes, and sometimes the best version of a screen is the third or fourth one you build. The more logic you attach to the view itself, the more expensive that iteration becomes.

A ViewModel gives the screen a stable behavioral boundary. The view can change from a list to sections, from a sheet to a navigation stack, or from a compact widget-like layout to a richer dashboard. The behavior remains in one place.

@MainActor
@Observable
final class SubscriptionListViewModel {
    var subscriptions: [Subscription] = []
    var searchText = ""
    var isLoading = false
    var errorMessage: String?

    private let repository: SubscriptionRepository

    init(repository: SubscriptionRepository) {
        self.repository = repository
    }

    var filteredSubscriptions: [Subscription] {
        guard !searchText.isEmpty else {
            return subscriptions
        }

        return subscriptions.filter {
            $0.name.localizedCaseInsensitiveContains(searchText)
        }
    }

    func load() async {
        isLoading = true
        defer { isLoading = false }

        do {
            subscriptions = try await repository.fetchSubscriptions()
            errorMessage = nil
        } catch {
            errorMessage = "Subscriptions could not be loaded."
        }
    }
}

The view can now focus on rendering filteredSubscriptions, showing progress, and presenting errors. It does not need to know where data comes from or how the screen recovers when loading fails.

The new Observation system makes MVVM better

Old SwiftUI MVVM often carried a lot of boilerplate. ObservableObject, @Published, @StateObject, @ObservedObject, and object lifetime rules made the pattern feel heavier than it should have been.

With Swift’s Observation framework, ViewModels are cleaner. @Observable removes much of the noise, and @Bindable makes two-way bindings explicit when the view needs them.

struct SubscriptionListView: View {
    @State private var viewModel: SubscriptionListViewModel

    init(repository: SubscriptionRepository) {
        _viewModel = State(
            initialValue: SubscriptionListViewModel(repository: repository)
        )
    }

    var body: some View {
        List(viewModel.filteredSubscriptions) { subscription in
            Text(subscription.name)
        }
        .searchable(text: $viewModel.searchText)
        .task {
            await viewModel.load()
        }
    }
}

This is not architecture for its own sake. It is a small boundary between rendering and behavior.

MVVM helps me test what matters

I do not want to test SwiftUI layout unless there is a very specific reason. I want to test decisions.

Does the screen show active subscriptions by default? Does search ignore case? Does a failed import expose the right error state? Does saving require a valid billing date? Does a renewal reminder get scheduled only when the user enabled reminders?

Those are not view questions. They are behavior questions.

When behavior lives in a ViewModel, it can be tested directly, quickly, and without UI machinery.

@Test
func searchFiltersSubscriptionsByName() async throws {
    let repository = MockSubscriptionRepository(items: [
        Subscription(name: "iCloud"),
        Subscription(name: "Netflix")
    ])
    let viewModel = SubscriptionListViewModel(repository: repository)

    await viewModel.load()
    viewModel.searchText = "cloud"

    #expect(viewModel.filteredSubscriptions.map(\.name) == ["iCloud"])
}

The value is not that I can say “this app uses MVVM.” The value is that I can change the UI without losing confidence in the behavior.

MVVM is not a place to hide everything

The mistake is treating the ViewModel as a junk drawer.

A ViewModel should not become the place where every helper, formatter, API call, navigation rule, and domain decision goes to live forever. If the logic belongs to the domain, it should move into a domain type. If it belongs to persistence, it should live behind a repository or service. If it belongs to formatting, it can be a formatter or computed presentation property.

The ViewModel’s job is coordination.

It prepares state for the view. It responds to user intent. It calls dependencies. It translates results into renderable state. It should be boring, explicit, and easy to scan.

When MVVM feels bad in SwiftUI, it is often because the ViewModel has become too powerful. That is not a problem with MVVM. That is a boundary problem.

I do not use MVVM for every view

Some SwiftUI views are just views, and they should stay that way.

A reusable row does not need a ViewModel. A button style does not need a ViewModel. A small local form might only need @State. A detail section that receives a value and renders it should remain a plain View.

I usually reach for a ViewModel when a screen has at least one of these characteristics:

  • It loads or mutates data asynchronously
  • It has multiple loading, empty, error, and success states
  • It coordinates more than one dependency
  • It contains meaningful validation or business rules
  • It needs focused unit tests
  • It is likely to be redesigned while the behavior should remain stable

That is the practical line for me. MVVM is a tool for screen behavior, not a tax on every View.

SwiftUI did not remove architecture

SwiftUI removed a lot of UI ceremony. It did not remove complexity from apps.

The complexity moved. It now lives in state ownership, data flow, side effects, async work, persistence, navigation, and the boundary between product behavior and visual presentation.

MVVM is still useful because it gives that complexity a place to live without making the view responsible for everything. Used carefully, it supports SwiftUI’s strengths instead of fighting them.

My current approach is simple:

  • Keep simple views simple
  • Use ViewModels for screen-level behavior
  • Keep domain logic out of ViewModels when it deserves its own type
  • Use Observation to reduce boilerplate
  • Treat testability as a design signal

I am not using MVVM because it is the only correct architecture for SwiftUI. I am using it because, in production apps, it continues to create clear boundaries at a very reasonable cost.

That tradeoff is still worth it.