DEV Community

Cover image for MVVM Beyond the Buzzword: A Practical Guide
Konstantin Shkurko
Konstantin Shkurko

Posted on

MVVM Beyond the Buzzword: A Practical Guide

This article isn't another documentation rehash. We'll examine why "pure" MVVM from textbooks falls apart in real projects, how to transform ViewModel from a garbage dump for logic into a clear state machine, and why navigation in architecture is always a pain that you need to know how to handle. You'll learn to separate data from its presentation so that tests write themselves, and your colleagues don't curse you during code review.

If you've ever opened a project where the ViewModel turned into a garbage heap of a couple thousand lines, congratulations—you've seen "smoker's MVVM." It usually happens like this: a developer hears that you can't write logic in View, and joyfully moves everything, including date formatting and transition logic, into the ViewModel. As a result, we get the same Massive View Controller, just with a different sauce and an even more complex lifecycle.

I hold the opinion that architecture isn't about how you organize files into folders, but about how you manage complexity and state. Let's break down how to make MVVM work for you, not against you.

Why MVVM Often Fails in Practice

Most problems with MVVM stem from a misunderstanding of responsibilities. Often, ViewModel is perceived as "the place where I put everything that didn't fit in View."

Main reasons for failure:

  1. Violation of encapsulation: View knows too much about ViewModel's internals, or even worse, ViewModel holds references to UI components. If your ViewModel has import UIKit (or any other UI library), you have problems.
  2. Lack of clear State: Variables like @Published var name, @Published var isLoading, @Published var error live their own lives. As a result, you can end up in a state where both the spinner is spinning and the error is showing simultaneously. This is an "invalid state," and it's a sin.
  3. Navigation logic inside VM: ViewModel shouldn't decide where to go next. Its job is to say: "I'm done, data is saved." But who leads the user where after that—that's the job of a coordinator or router.

Core Principles: Separation, Testability, Unidirectional Data Flow

Forget about Two-Way Binding as a standard. It's appropriate in simple input forms, but on complex screens, it turns data flow into chaos. The future (and present) belongs to Unidirectional Data Flow (UDF).

  • View sends an Action (button press, viewDidLoad).
  • ViewModel processes the Action, calls the service, and updates State.
  • View subscribes to State and re-renders.

This makes the system predictable. You always know which event led to the state change. Plus, it dramatically simplifies testing: you just feed in an action and check if the final state matches what's expected.

ViewModel as a State Machine (Not Just a Bag of Properties)

Instead of a scattering of disparate properties, I prefer to use a single State. The ideal tool for this is an enum.

final class ProductListViewModel: ObservableObject {
    enum State {
        case idle
        case loading
        case loaded([Product])
        case error(String)
    }

    @Published private(set) var state: State = .idle

    private let repository: ProductRepositoryProtocol

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

    func loadProducts() {
        state = .loading
        repository.fetchProducts { [weak self] result in
            switch result {
            case .success(let products):
                self?.state = .loaded(products)
            case .failure(let error):
                self?.state = .error(error.localizedDescription)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Why is this cool? Because now View is maximally dumb. It just "renders" the state. In SwiftUI, this turns into an elegant switch inside body. I'm categorically against logic in View, even if it's a simple if-else. The less View "thinks," the fewer chances of catching weird bugs during rendering.

Handling Side Effects: Networking, Analytics, Navigation

ViewModel is a dispatcher. It shouldn't go to the network or write to the database itself. It calls an abstraction (protocol).

Navigation

I'm a proponent of the Coordinator pattern. ViewModel should communicate the need for navigation through a closure or delegate.

final class LoginViewModel: ObservableObject {
    var onLoginSuccess: (() -> Void)?

    func handleLogin() {
        // ... authorization logic
        onLoginSuccess?()
    }
}
Enter fullscreen mode Exit fullscreen mode

Analytics

Don't clutter business logic methods with Analytics.log(...) calls. This is a "side effect." It's best to extract this into separate decorators or use observers that watch for state changes. But if the project is small, I allow injecting an analytics service into ViewModel, as long as it doesn't turn into spaghetti.

Binding Strategies: Combine vs Closures vs @Published

The choice of tool depends on your stack and religion.

  • @Published (SwiftUI): The simplest and most concise option. But be careful: updates happen in objectWillChange, which sometimes leads to nuances in the lifecycle.
  • Combine: Gives you the power of operators (debounce, filter, combineLatest). If you have complex input with "on-the-fly" validation, Combine is indispensable. But debugging long chains is a special kind of masochism.
  • Closures: Old-school and the fastest option. No external dependencies, no magic. If you're writing a library, this is the best choice to avoid forcing Combine or RxSwift on the user.

I personally prefer Combine for iOS 13+, but I try to keep chains short. If a chain is more than 5-6 operators—it's time to break it into parts or extract the logic into a separate method.

Testing ViewModels Without UIKit/SwiftUI

If you can't test a ViewModel without creating an instance of UIViewController or View—your architecture has failed. A test should look roughly like this:

func test_onLoad_setsLoadingState() {
    let mockRepository = MockProductRepository()
    let sut = ProductListViewModel(repository: mockRepository)

    sut.loadProducts()

    if case .loading = sut.state {
        // success
    } else {
        XCTFail("Expected .loading state")
    }
}
Enter fullscreen mode Exit fullscreen mode

I always use Dependency Injection through the initializer. This allows you to easily slip in mocks. Testing async code in Combine is a bit more complex (you need Expectations), but it's still orders of magnitude faster than running UI tests.

Anti-Patterns to Avoid

  1. Massive ViewModel: If your VM has crossed 500 lines—cut it. Extract formatting logic into Formatter, data work into Service, and complex transformations into Use Case (hello, Clean Architecture).
  2. Leaky Views: Passing UI objects into ViewModel. Never pass UIImage or NSAttributedString. Pass Data or just String. ViewModel should live in the world of pure logic.
  3. Shared ViewModels: Using the same instance model for different screens via Singleton. This is a direct path to the state of "someone changed data on the third screen, and everything crashed for me." Each screen gets its own ViewModel. If you need to share data—use a common Service or Storage.

MVVM isn't a dogma, it's a tool. It scales beautifully if you don't try to make it the "architecture of the entire application." In reality, MVVM works great with coordinators for navigation and services for business logic.

The main thing is to remember: ViewModel is responsible for what to show, and View is responsible for how it looks. If you separate these concepts in your head, your code will become cleaner, and your sleep—more peaceful.

Top comments (0)