medium.com 6 min read Original Source

MVVM in SwiftUI: When You Need a View Model (and When You Don’t)

Not a Medium Member? “Read For Free”

When you start building bigger SwiftUI apps, your views can get messy quickly. One screen loads data, another shows errors, a third handles button taps, and before long your body is 200 lines long.

That’s when people usually hear about MVVM. But some developers start creating a view model for every view, even a view that only shows a single label. That adds extra files and makes the code harder to follow, without making it any better.

In this article we’ll cover:

  • What MVVM means?
  • How to build a real screen with a view model in SwiftUI
  • Where your business logic should go
  • How to test a view model without touching the UI
  • When you should skip the view model

Let’s start.

What is MVVM?

MVVM stands for Model — View — ViewModel. It’s a way of splitting your code into three parts, where each part has one job.

Model: Your app’s data and the rules that go with it. For example, a TodoItem with a title and a “done” flag.

View: What the user sees. In SwiftUI, this is your struct that conforms to View. Its job is to show things on screen and pass on what the user does (taps, swipes, typing).

ViewModel: The middle layer. It takes data from the model, gets it ready for the screen, and handles actions like “load the list” or “mark this task as done.” The view reads from it and calls its methods.

One important rule: the view model never knows about the view. It doesn’t hold a reference to it and doesn’t tell it what to draw. It just exposes state, and SwiftUI redraws the view when that state changes.

A simple way:

The View asks, “What should I show?”
The ViewModel answers, “Here’s the list, here’s whether we’re loading, and here’s an error message if something broke.”
The Model is where the actual data comes from.

SwiftUI is more relaxed about this than UIKit was. You can still use @State, environment values, and plain model properties directly in your views. MVVM is a tool you reach for when it makes things clearer, not a rule you have to follow everywhere.

Building a Real Example: A To-Do List Screen

Let’s build a simple to-do screen that:

  1. Loads tasks from somewhere (an API, a database or any other source)
  2. Shows a loading spinner while it works
  3. Shows an error if loading fails
  4. Lets the user mark a task as done

Step 1: The Model

We’ll start small:

import Foundation

struct TodoItem: Identifiable, Equatable {
    let id: UUID
    var title: String
    var isDone: Bool
}
import Foundation

struct TodoItem: Identifiable, Equatable {
    let id: UUID
    var title: String
    var isDone: Bool
}

Nothing special here, just plain data.

Step 2: A Service Protocol

Our view model needs to fetch tasks and update them. Instead of tying it to one real implementation, we describe what we need with a protocol:

[TodoItem]
func markDone(id: UUID) async throws
}” data-toggle-ms=”1200″ aria-label=”Copy code” data-nosnippet=”” class=”code-copy-btn absolute right-3 top-3 size-8 p-1.5 flex items-center justify-center bg-black/50 text-white rounded-md transition-colors duration-200 cursor-pointer hover:bg-black/70″>

@MainActor
protocol TodoService {
    func fetchTodos() async throws -> [TodoItem]
    func markDone(id: UUID) async throws
}
@MainActor
protocol TodoService {
    func fetchTodos() async throws -> [TodoItem]
    func markDone(id: UUID) async throws
}

Why a protocol? In the real app we can plug in a network or database version. In tests we can plug in a fake one that returns whatever we want. The view model doesn’t care which one it gets.

Step 3: The View Model

This is where the screen’s logic lives:

import Observation

@Observable
@MainActor
final class TodoListViewModel {
    private let service: any TodoService

    private(set) var todos: [TodoItem] = []
    private(set) var isLoading = false
    private(set) var errorMessage: String?

    var searchText = ""

    var visibleTodos: [TodoItem] {
        guard !searchText.isEmpty else { return todos }
        return todos.filter {
            $0.title.localizedCaseInsensitiveContains(searchText)
        }
    }

    var remainingText: String {
        let count = todos.filter { !$0.isDone }.count
        return count == 1 ? "1 task left" : "(count) tasks left"
    }

    init(service: any TodoService) {
        self.service = service
    }

    func loadTodos() async {
        isLoading = true
        errorMessage = nil
        defer { isLoading = false }

        do {
            todos = try await service.fetchTodos()
        } catch {
            errorMessage = "Couldn't load your tasks. Please try again."
        }
    }

    func complete(_ todo: TodoItem) async {
        do {
            try await service.markDone(id: todo.id)
            if let index = todos.firstIndex(where: { $0.id == todo.id }) {
                todos[index].isDone = true
            }
        } catch {
            errorMessage = "Couldn't update "(todo.title)"."
        }
    }
}
import Observation

@Observable
@MainActor
final class TodoListViewModel {
    private let service: any TodoService

    private(set) var todos: [TodoItem] = []
    private(set) var isLoading = false
    private(set) var errorMessage: String?

    var searchText = ""

    var visibleTodos: [TodoItem] {
        guard !searchText.isEmpty else { return todos }
        return todos.filter {
            $0.title.localizedCaseInsensitiveContains(searchText)
        }
    }

    var remainingText: String {
        let count = todos.filter { !$0.isDone }.count
        return count == 1 ? "1 task left" : "(count) tasks left"
    }

    init(service: any TodoService) {
        self.service = service
    }

    func loadTodos() async {
        isLoading = true
        errorMessage = nil
        defer { isLoading = false }

        do {
            todos = try await service.fetchTodos()
        } catch {
            errorMessage = "Couldn't load your tasks. Please try again."
        }
    }

    func complete(_ todo: TodoItem) async {
        do {
            try await service.markDone(id: todo.id)
            if let index = todos.firstIndex(where: { $0.id == todo.id }) {
                todos[index].isDone = true
            }
        } catch {
            errorMessage = "Couldn't update "(todo.title)"."
        }
    }
}

A few things to notice:

  • @Observable tells SwiftUI to watch this object. When a property the view reads changes, SwiftUI redraws that part of the screen. No @Published needed.
  • @MainActor makes sure all UI-related state changes happen on the main thread.
  • private(set) means the view can read todos, isLoading, and errorMessage, but can’t change them directly. Only the view model can. This keeps the data flow predictable.
  • Computed properties like remainingText and visibleTodos turn raw data into exactly what the screen needs. The view doesn’t have to do any counting or filtering.
  • Methods are named after what the user does: loadTodos() and complete(_:). The view doesn’t need to know whether that means a network call, a database write, or something else.

Step 4: The View

Now the view only has to show the state and pass on taps:

import SwiftUI

struct TodoListView: View {
    @State private var viewModel: TodoListViewModel

    init(service: any TodoService) {
        _viewModel = State(initialValue: TodoListViewModel(service: service))
    }

    var body: some View {
        NavigationStack {
            content
                .navigationTitle("My Tasks")
                .toolbar {
                    Text(viewModel.remainingText)
                        .font(.footnote)
                        .foregroundStyle(.secondary)
                }
                .searchable(text: $viewModel.searchText)
                .task {
                    await viewModel.loadTodos()
                }
        }
    }

    @ViewBuilder
    private var content: some View {
        if viewModel.isLoading {
            ProgressView("Loading tasks…")
        } else if let message = viewModel.errorMessage {
            VStack(spacing: 12) {
                Text(message)
                Button("Retry") {
                    Task { await viewModel.loadTodos() }
                }
            }
        } else {
            List(viewModel.visibleTodos) { todo in
                HStack {
                    Text(todo.title)
                        .strikethrough(todo.isDone)
                    Spacer()
                    if todo.isDone {
                        Image(systemName: "checkmark.circle.fill")
                            .foregroundStyle(.green)
                    } else {
                        Button("Done") {
                            Task { await viewModel.complete(todo) }
                        }
                        .buttonStyle(.bordered)
                    }
                }
            }
        }
    }
}
import SwiftUI

struct TodoListView: View {
    @State private var viewModel: TodoListViewModel

    init(service: any TodoService) {
        _viewModel = State(initialValue: TodoListViewModel(service: service))
    }

    var body: some View {
        NavigationStack {
            content
                .navigationTitle("My Tasks")
                .toolbar {
                    Text(viewModel.remainingText)
                        .font(.footnote)
                        .foregroundStyle(.secondary)
                }
                .searchable(text: $viewModel.searchText)
                .task {
                    await viewModel.loadTodos()
                }
        }
    }

    @ViewBuilder
    private var content: some View {
        if viewModel.isLoading {
            ProgressView("Loading tasks…")
        } else if let message = viewModel.errorMessage {
            VStack(spacing: 12) {
                Text(message)
                Button("Retry") {
                    Task { await viewModel.loadTodos() }
                }
            }
        } else {
            List(viewModel.visibleTodos) { todo in
                HStack {
                    Text(todo.title)
                        .strikethrough(todo.isDone)
                    Spacer()
                    if todo.isDone {
                        Image(systemName: "checkmark.circle.fill")
                            .foregroundStyle(.green)
                    } else {
                        Button("Done") {
                            Task { await viewModel.complete(todo) }
                        }
                        .buttonStyle(.bordered)
                    }
                }
            }
        }
    }
}

Look at how readable the view is. It doesn’t fetch anything, filter anything, or count anything. It just asks the view model “what’s the state?” and draws it.

A Note on Property Wrappers

With @Observable, you pick the wrapper based on who owns the view model:

  • This view creates and owns the view model — @State
  • The view model is passed in and you only read values or call methods — A plain let or var
  • The view model is passed in and you need bindings (like $viewModel.searchText) — @Bindable

For example, if we moved the search bar into a child view, it could look like this:

struct TodoSearchHeader: View {
    @Bindable var viewModel: TodoListViewModel

    var body: some View {
        TextField("Search tasks", text: $viewModel.searchText)
            .textFieldStyle(.roundedBorder)
    }
}
struct TodoSearchHeader: View {
    @Bindable var viewModel: TodoListViewModel

    var body: some View {
        TextField("Search tasks", text: $viewModel.searchText)
            .textFieldStyle(.roundedBorder)
    }
}

Where Should Business Logic Go?

This part confuse up a lot of people. The view model is not a dumping ground for everything.

A good view model handles presentation logic:

  • Turning data into display-ready text (like “3 tasks left”)
  • Tracking loading and error states
  • Filtering or sorting for the screen
  • Deciding what happens when the user taps something

The real work, such as talking to an API, saving to a database, or caching, belongs in a separate type like our TodoService.

In our example, the service actually marks the task as done. The view model just calls it and updates the screen based on the result.

If you skip this split, your view model slowly turns into a giant class that knows about networking, storage, analytics, push notifications, and everything else. That’s just a “massive view controller” with a new name.

Testing the View Model

This is where MVVM really looks good. Since all the logic lives in a plain Swift class, you can test it without rendering any UI at all.

First, a fake service we fully control:

[TodoItem] {
if shouldFail { throw FakeError() }
return todosToReturn
}

func markDone(id: UUID) async throws {
if shouldFail { throw FakeError() }
completedIDs.append(id)
}
}” data-toggle-ms=”1200″ aria-label=”Copy code” data-nosnippet=”” class=”code-copy-btn absolute right-3 top-3 size-8 p-1.5 flex items-center justify-center bg-black/50 text-white rounded-md transition-colors duration-200 cursor-pointer hover:bg-black/70″>

@MainActor
final class MockTodoService: TodoService {
    var todosToReturn: [TodoItem] = []
    var shouldFail = false
    private(set) var completedIDs: [UUID] = []

    struct FakeError: Error {}

    func fetchTodos() async throws -> [TodoItem] {
        if shouldFail { throw FakeError() }
        return todosToReturn
    }

    func markDone(id: UUID) async throws {
        if shouldFail { throw FakeError() }
        completedIDs.append(id)
    }
}
@MainActor
final class MockTodoService: TodoService {
    var todosToReturn: [TodoItem] = []
    var shouldFail = false
    private(set) var completedIDs: [UUID] = []

    struct FakeError: Error {}

    func fetchTodos() async throws -> [TodoItem] {
        if shouldFail { throw FakeError() }
        return todosToReturn
    }

    func markDone(id: UUID) async throws {
        if shouldFail { throw FakeError() }
        completedIDs.append(id)
    }
}

Now some tests using Swift Testing:

import Testing
import Foundation

@MainActor
struct TodoListViewModelTests {

    @Test func loadsTodosSuccessfully() async {
        let service = MockTodoService()
        service.todosToReturn = [
            TodoItem(id: UUID(), title: "Buy milk", isDone: false),
            TodoItem(id: UUID(), title: "Write article", isDone: true)
        ]
        let viewModel = TodoListViewModel(service: service)

        await viewModel.loadTodos()

        #expect(viewModel.todos.count == 2)
        #expect(viewModel.remainingText == "1 task left")
        #expect(viewModel.isLoading == false)
        #expect(viewModel.errorMessage == nil)
    }

    @Test func showsErrorWhenLoadingFails() async {
        let service = MockTodoService()
        service.shouldFail = true
        let viewModel = TodoListViewModel(service: service)

        await viewModel.loadTodos()

        #expect(viewModel.todos.isEmpty)
        #expect(viewModel.errorMessage != nil)
    }

    @Test func completingATaskMarksItDone() async {
        let todo = TodoItem(id: UUID(), title: "Go for a run", isDone: false)
        let service = MockTodoService()
        service.todosToReturn = [todo]
        let viewModel = TodoListViewModel(service: service)

        await viewModel.loadTodos()
        await viewModel.complete(todo)

        #expect(service.completedIDs == [todo.id])
        #expect(viewModel.todos.first?.isDone == true)
        #expect(viewModel.remainingText == "0 tasks left")
    }

    @Test func searchFiltersVisibleTodos() async {
        let service = MockTodoService()
        service.todosToReturn = [
            TodoItem(id: UUID(), title: "Buy milk", isDone: false),
            TodoItem(id: UUID(), title: "Call mom", isDone: false)
        ]
        let viewModel = TodoListViewModel(service: service)

        await viewModel.loadTodos()
        viewModel.searchText = "milk"

        #expect(viewModel.visibleTodos.map(.title) == ["Buy milk"])
    }
}
import Testing
import Foundation

@MainActor
struct TodoListViewModelTests {

    @Test func loadsTodosSuccessfully() async {
        let service = MockTodoService()
        service.todosToReturn = [
            TodoItem(id: UUID(), title: "Buy milk", isDone: false),
            TodoItem(id: UUID(), title: "Write article", isDone: true)
        ]
        let viewModel = TodoListViewModel(service: service)

        await viewModel.loadTodos()

        #expect(viewModel.todos.count == 2)
        #expect(viewModel.remainingText == "1 task left")
        #expect(viewModel.isLoading == false)
        #expect(viewModel.errorMessage == nil)
    }

    @Test func showsErrorWhenLoadingFails() async {
        let service = MockTodoService()
        service.shouldFail = true
        let viewModel = TodoListViewModel(service: service)

        await viewModel.loadTodos()

        #expect(viewModel.todos.isEmpty)
        #expect(viewModel.errorMessage != nil)
    }

    @Test func completingATaskMarksItDone() async {
        let todo = TodoItem(id: UUID(), title: "Go for a run", isDone: false)
        let service = MockTodoService()
        service.todosToReturn = [todo]
        let viewModel = TodoListViewModel(service: service)

        await viewModel.loadTodos()
        await viewModel.complete(todo)

        #expect(service.completedIDs == [todo.id])
        #expect(viewModel.todos.first?.isDone == true)
        #expect(viewModel.remainingText == "0 tasks left")
    }

    @Test func searchFiltersVisibleTodos() async {
        let service = MockTodoService()
        service.todosToReturn = [
            TodoItem(id: UUID(), title: "Buy milk", isDone: false),
            TodoItem(id: UUID(), title: "Call mom", isDone: false)
        ]
        let viewModel = TodoListViewModel(service: service)

        await viewModel.loadTodos()
        viewModel.searchText = "milk"

        #expect(viewModel.visibleTodos.map(.title) == ["Buy milk"])
    }
}

These tests are fast, reliable, and easy to read. You’re testing behavior (“when loading fails, show an error”) instead of fighting with the view hierarchy. You can still write UI tests for layout and flows, but most of your logic is now covered with simple unit tests.

Does Every View Need a View Model?

No.

This is probably the most useful lesson in the whole article. A view model that just copies values from a model and passes them along adds nothing. It’s one more file to open and one more layer to dig through.

Take a single row in our list:

struct TodoRowView: View {
    let todo: TodoItem

    var body: some View {
        Label(
            todo.title,
            systemImage: todo.isDone ? "checkmark.circle.fill" : "circle"
        )
    }
}
struct TodoRowView: View {
    let todo: TodoItem

    var body: some View {
        Label(
            todo.title,
            systemImage: todo.isDone ? "checkmark.circle.fill" : "circle"
        )
    }
}

Creating a TodoRowViewModel for this would make the code harder to navigate and give you nothing worth testing. Keep it simple.

Consider adding a view model when your view:

  • Runs async work like loading or saving
  • Transforms data before showing it (formatting, filtering, counting)
  • Handles validation or error messages
  • Has logic you want to test on its own
  • Talks to one or more services

Thank you for reading until the end. Before you go: