When MVVM Goes Wrong in SwiftUI
MVVM is useful until the ViewModel becomes the place where every problem goes to hide.
That is the failure mode I see most often in SwiftUI codebases. The view starts simple. The ViewModel starts simple. Then a new requirement arrives. Then analytics. Then navigation. Then formatting. Then caching. Then permission logic. Then retry behavior. After a few months, the ViewModel is no longer a presentation model. It is the feature.
At that point, MVVM is still the label on the folder, but it is not doing much architectural work.
#The ViewModel Is Not a Smaller View Controller
In UIKit, massive view controllers were a common pain. MVVM helped because it moved presentation state and behavior out of the view controller.
In SwiftUI, the view is already more declarative. If we blindly move every decision into the ViewModel, we can recreate the same problem under a new name.
final class CheckoutViewModel: ObservableObject {
func pay() async {
validateForm()
buildRequest()
callAPI()
cacheReceipt()
trackAnalytics()
decideNextScreen()
showErrorIfNeeded()
}
}
This code might be testable in a technical sense, but the responsibility is muddy. The ViewModel validates, talks to infrastructure, persists data, tracks analytics, handles errors, and owns navigation decisions.
The issue is not MVVM. The issue is that the ViewModel became the boundary for everything.
#A Better Role for the ViewModel
A SwiftUI ViewModel is most useful when it prepares state for the view and maps use case results into renderable state.
@MainActor
final class ProductsViewModel: ObservableObject, ProductsOutput {
@Published private(set) var state: State = .loading
func didLoadProducts(_ products: [Product]) {
state = .loaded(products)
}
func didFailLoadingProducts() {
state = .failed("Could not load products.")
}
}
The ViewModel owns UI state. It maps use case results into something the view can render.
That is enough.
The use case can decide how products are fetched and report a specific result. The repository can decide where data comes from. A decorator can add analytics or logging. A flow or router can own navigation if navigation becomes complex.
This connects directly to Clean MVVM in SwiftUI and Practical Polymorphism in Swift: composition lets the ViewModel stay focused.
#Mistake: Hiding Dependencies in the Environment
SwiftUI environment values are convenient. They are also easy to overuse.
struct ProductsView: View {
@Environment(\.productsRepository) private var repository
var body: some View {
// ...
}
}
This can look clean because the initializer is empty. But the feature still depends on a repository. The dependency is just invisible.
If the repository is essential to the feature's behavior, prefer making it explicit at the feature boundary:
struct ProductsView: View {
@ObservedObject var viewModel: ProductsViewModel
let onAppear: () async -> Void
var body: some View {
ProductsContent(state: viewModel.state)
.task { await onAppear() }
}
}
Use the environment for values that feel environmental. Use initializers for visible feature dependencies such as ViewModels and intent closures, while the factory or composition root wires those closures to use cases. Because this ViewModel is injected, @ObservedObject communicates ownership better than @StateObject. Use @StateObject when the view creates the observable object and owns its lifetime.
#Mistake: ViewModel-Owned Navigation
Navigation is one of the fastest ways to overload a ViewModel.
final class LoginViewModel: ObservableObject {
func login() async {
// ...
navigationPath.append(.home)
}
}
Sometimes this is fine for small features. But as the app grows, ViewModels can become coupled to the entire navigation graph. A login feature should not necessarily know what the home screen is, how onboarding works, or whether the app is inside a tab.
A cleaner design is to keep navigation as a screen-level event, not a domain output. The ViewModel can translate a successful login into a small event for its parent:
enum LoginScreenEvent {
case loginCompleted
}
@MainActor
final class LoginViewModel: ObservableObject {
var onEvent: (LoginScreenEvent) -> Void = { _ in }
@Published private(set) var isLoading = false
func didFinishLogin() {
isLoading = false
onEvent(.loginCompleted)
}
}
The login use case can still report a domain-level result, such as success or failure, to the ViewModel. The ViewModel updates its UI state and raises a screen event.
The factory can wire that event handler, but it should not decide where the app goes next:
struct LoginScreen: View {
@ObservedObject var viewModel: LoginViewModel
var body: some View {
LoginForm(
isLoading: viewModel.isLoading,
onSubmit: { /* trigger login use case */ }
)
}
}
struct LoginFeatureFactory {
func make(
onEvent: @escaping (LoginScreenEvent) -> Void
) -> LoginScreen {
let viewModel = LoginViewModel()
viewModel.onEvent = onEvent
return LoginScreen(viewModel: viewModel)
}
}
LoginScreen receives an already-created ViewModel, so it observes it with @ObservedObject. The flow owns the navigation meaning of that event:
@MainActor
final class LoginFlow: ObservableObject {
enum Route: Hashable {
case home
}
@Published var path = NavigationPath()
private let loginFactory: LoginFeatureFactory
init(loginFactory: LoginFeatureFactory) {
self.loginFactory = loginFactory
}
func makeLoginScreen() -> LoginScreen {
loginFactory.make(onEvent: handle)
}
private func handle(_ event: LoginScreenEvent) {
switch event {
case .loginCompleted:
path.append(Route.home)
}
}
}
struct LoginFlowView: View {
@StateObject private var flow = LoginFlow(
loginFactory: LoginFeatureFactory()
)
var body: some View {
NavigationStack(path: $flow.path) {
flow.makeLoginScreen()
.navigationDestination(for: LoginFlow.Route.self) { route in
switch route {
case .home:
HomeScreen()
}
}
}
}
}
This is the same idea behind The Flow Pattern in UIKit, adapted to SwiftUI: screens should not have to know the whole journey.
#Mistake: Formatting Everywhere
Formatting can live in the ViewModel, but it should not turn into business logic.
struct ProductRowViewState: Equatable {
let title: String
let subtitle: String
let price: String
}
This kind of state is useful. SwiftUI views become easy to render and easy to snapshot mentally.
But if formatting requires policy, rules, or decisions the business cares about, that logic probably deserves its own type.
struct ProductPriceTextFormatter {
let currencyFormatter: CurrencyFormatting
func string(from price: Money) -> String {
currencyFormatter.format(price)
}
}
The ViewModel can use that focused formatter while still owning the view state:
@MainActor
final class ProductRowViewModel: ObservableObject {
@Published private(set) var state: ProductRowViewState
private let priceFormatter: ProductPriceTextFormatter
init(product: Product, priceFormatter: ProductPriceTextFormatter) {
self.priceFormatter = priceFormatter
self.state = ProductRowViewState(
title: product.name,
subtitle: product.categoryName,
price: priceFormatter.string(from: product.price)
)
}
}
The ViewModel does not have to become the place where every display rule lives. It can ask a small, named collaborator to handle formatting policy, then expose the final strings the view needs.
#Mistake: Testing Every Published Change
Because ViewModels expose state, it is tempting to write tests that assert every intermediate @Published value.
That can make tests brittle.
Prefer testing meaningful behavior:
func test_onAppear_showsProductsOnSuccess() async {
let viewModel = ProductsViewModel()
viewModel.didLoadProducts([.keyboard])
XCTAssertEqual(viewModel.state, .loaded([.keyboard]))
}
If loading state is an important user-visible behavior, test it. If an intermediate state is just an implementation detail, be careful. Tests should protect how the ViewModel maps meaningful results into state, not freeze how a use case is triggered.
#What You Should Remember
MVVM goes wrong when the ViewModel becomes the only architectural boundary in the feature.
Keep ViewModels focused on view state and small UI events. Move business operations into use cases. Move concrete dependencies to the composition root. Move cross-cutting behavior into decorators. Move complex navigation decisions to the parent, coordinator, flow, or app composition layer.
MVVM is not the architecture. It is one useful shape inside the architecture.
Read next
Unidirectional Data Flow in SwiftUI Apps
Learn unidirectional data flow in SwiftUI with view intents, use case outputs, ViewModel state, and flow-owned navigation.