Popular Searches
Popular Course Categories
Popular Courses

Implementing MVVM with SwiftUI and UIKit

Implementing MVVM with SwiftUI and UIKit

iOS Architecture and State Management

Implementing MVVM with SwiftUI and UIKit

Course: iOS App Development

Topic: Implementing the Model-View-ViewModel (MVVM) Architecture with SwiftUI and UIKit

Course Link: iOS App Development Training at JustAcademy

Demo Link: Register for a Course Demo

Website: JustAcademy


1. Introduction to MVVM Architecture

MVVM stands for Model-View-ViewModel. It is a software architectural pattern that separates an application's data and business logic from its user interface. MVVM helps developers create iOS applications that are easier to maintain, test, extend, and debug.

In iOS development, MVVM can be used with both SwiftUI and UIKit. SwiftUI provides built-in mechanisms for observing state and updating views, while UIKit commonly uses property observers, closures, Combine, delegates, or other communication techniques to connect the ViewModel with the interface.

Without a clear architecture, a view controller or SwiftUI view can become responsible for networking, data processing, validation, navigation, and UI updates. This creates tightly coupled code that becomes difficult to maintain as an application grows. MVVM distributes these responsibilities into distinct components.

Learning Objectives

  • Understand the Model, View, and ViewModel responsibilities.
  • Learn how data moves through an MVVM-based application.
  • Implement MVVM using SwiftUI and observable state.
  • Implement MVVM with UIKit and connect the ViewModel to view controllers.
  • Handle loading states, errors, user input, and asynchronous requests.
  • Understand dependency injection, testability, and common architectural mistakes.

2. What Are the Three Components of MVVM?

2.1 Model

The Model represents the application's data and domain concepts. It may contain structures, classes, enumerations, validation rules, and domain-specific operations. Models should generally not depend on SwiftUI views or UIKit view controllers.

import Foundation

struct User: Identifiable, Codable {
    let id: Int
    let name: String
    let email: String
}

In this example, User describes a user record. It does not decide how that record is displayed on the screen.

2.2 View

The View displays information and receives user interactions. In SwiftUI, the View is typically a structure conforming to View. In UIKit, it can consist of a UIViewController, UIView, labels, buttons, tables, and collection views.

The View should focus on presentation. It asks the ViewModel to perform an action and reflects the state exposed by that ViewModel.

2.3 ViewModel

The ViewModel connects the Model to the View. It prepares data for presentation, exposes observable state, processes user actions, coordinates application operations, and manages presentation-related logic such as loading and error messages.

A ViewModel should generally avoid directly manipulating UIKit views or constructing SwiftUI view hierarchies. Keeping it independent of the UI framework makes it easier to test.

3. How MVVM Works

  1. The user interacts with the View, such as tapping a button or entering a search query.
  2. The View calls an action or method on the ViewModel.
  3. The ViewModel validates the input and performs the required operation.
  4. The Model or a service provides data or performs a domain operation.
  5. The ViewModel updates its observable state.
  6. The View observes the changed state and refreshes the interface.

For example, when a user taps “Load Users,” the View calls viewModel.loadUsers(). The ViewModel requests data from a service, stores the result, and updates its loading state. The View then displays the user list or an error message.

4. MVVM Compared with Other Responsibilities

Component Main Responsibility Example
Model Represents application data User, Product, Order
View Displays state and captures interactions UserListView, UIViewController
ViewModel Transforms data into presentation state and handles UI actions UserListViewModel
Service or Repository Retrieves or persists data UserService, UserRepository

A service or repository is not one of the three core MVVM components, but adding one is often helpful. It prevents the ViewModel from becoming responsible for low-level networking, database access, or storage details.

5. Implementing MVVM with SwiftUI

SwiftUI uses a declarative UI approach. The interface describes what should appear for a given state, and SwiftUI updates the interface when observed state changes.

5.1 Create the Model

import Foundation

struct TaskItem: Identifiable {
    let id: UUID
    var title: String
    var isCompleted: Bool

    init(
        id: UUID = UUID(),
        title: String,
        isCompleted: Bool = false
    ) {
        self.id = id
        self.title = title
        self.isCompleted = isCompleted
    }
}

The TaskItem structure represents one task. Its identifier allows SwiftUI to distinguish tasks when rendering a list.

5.2 Create the ViewModel

For a straightforward example, use @Observable with a modern Swift toolchain. The @Observable macro is available in the Observation framework and works well with SwiftUI on supported deployment targets.

import Foundation
import Observation

@MainActor
@Observable
final class TaskListViewModel {
    var tasks: [TaskItem] = []
    var newTaskTitle = ""

    var remainingCount: Int {
        tasks.filter { !$0.isCompleted }.count
    }

    var canAddTask: Bool {
        !newTaskTitle.trimmingCharacters(
            in: .whitespacesAndNewlines
        ).isEmpty
    }

    func addTask() {
        let title = newTaskTitle.trimmingCharacters(
            in: .whitespacesAndNewlines
        )

        guard !title.isEmpty else { return }

        tasks.append(TaskItem(title: title))
        newTaskTitle = ""
    }

    func toggleTask(_ task: TaskItem) {
        guard let index = tasks.firstIndex(
            where: { $0.id == task.id }
        ) else { return }

        tasks[index].isCompleted.toggle()
    }

    func deleteTasks(at offsets: IndexSet) {
        tasks.remove(atOffsets: offsets)
    }
}

Important details:

  • @Observable makes changes to tracked properties observable by compatible SwiftUI views.
  • @MainActor isolates the ViewModel to the main actor, which is appropriate for this UI-facing state.
  • remainingCount is a derived value; it does not need to be stored separately.
  • canAddTask keeps input validation logic out of the View.
  • The ViewModel exposes methods for actions rather than requiring the View to edit the task array directly.

5.3 Create the SwiftUI View

import SwiftUI

struct TaskListView: View {
    @State private var viewModel = TaskListViewModel()

    var body: some View {
        NavigationStack {
            VStack(spacing: 16) {
                HStack {
                    TextField(
                        "Enter a task",
                        text: $viewModel.newTaskTitle
                    )
                    .textFieldStyle(.roundedBorder)

                    Button("Add") {
                        viewModel.addTask()
                    }
                    .disabled(!viewModel.canAddTask)
                }

                Text(
                    "Remaining tasks: \(viewModel.remainingCount)"
                )
                .font(.subheadline)

                List {
                    ForEach(viewModel.tasks) { task in
                        HStack {
                            Button {
                                viewModel.toggleTask(task)
                            } label: {
                                Image(
                                    systemName: task.isCompleted
                                        ? "checkmark.circle.fill"
                                        : "circle"
                                )
                            }
                            .buttonStyle(.plain)

                            Text(task.title)
                                .strikethrough(task.isCompleted)
                        }
                    }
                    .onDelete(perform: viewModel.deleteTasks)
                }
            }
            .padding()
            .navigationTitle("My Tasks")
        }
    }
}

This view displays the task input, add button, remaining-task count, and task list. It calls ViewModel methods when the user interacts with the interface. The ViewModel updates state, and SwiftUI redraws the relevant parts of the view.

5.4 Understanding SwiftUI Property Wrappers

Property Wrapper Typical Purpose Usage Notes
@State Owns local state or an observable reference Suitable for owning an @Observable ViewModel in a SwiftUI view.
@Observable Makes tracked properties observable Applied to a class, not a property.
@Bindable Creates bindings to properties of an observable object Useful when a child view needs a binding such as a text field binding.
@ObservedObject Observes an ObservableObject Common in projects using the Combine-based observation pattern.
@StateObject Owns an ObservableObject reference Commonly used when a SwiftUI view creates and manages the lifetime of that object.
@EnvironmentObject Reads a shared ObservableObject from the environment Useful for shared app state when appropriate; avoid making all dependencies global.

Do not mix the Observation framework and the older Combine-based approach without understanding their different requirements. For a legacy ViewModel that conforms to ObservableObject, use the matching property wrappers and @Published properties.

5.5 Alternative: Combine-Based SwiftUI ViewModel

import Foundation
import Combine

@MainActor
final class ProfileViewModel: ObservableObject {
    @Published private(set) var name = "Guest"

    func updateName(_ value: String) {
        name = value.trimmingCharacters(
            in: .whitespacesAndNewlines
        )
    }
}

A SwiftUI view can own this older-style ViewModel using @StateObject:

struct ProfileView: View {
    @StateObject private var viewModel = ProfileViewModel()

    var body: some View {
        VStack {
            Text("Hello, \(viewModel.name)")
            Button("Set Name") {
                viewModel.updateName("Alex")
            }
        }
    }
}

Use one observation strategy consistently for each ViewModel. The Observation framework is a modern option, while ObservableObject remains useful in existing projects and codebases.

6. Implementing MVVM with UIKit

UIKit does not automatically observe arbitrary ViewModel properties. A UIKit screen needs an explicit mechanism to communicate state changes to its view controller. Common options include Combine publishers, closures, delegates, or carefully designed callbacks.

6.1 Create a UIKit Model

import Foundation

struct Product {
    let id: Int
    let name: String
    let price: Double
}

6.2 Create a ViewModel with Combine

import Foundation
import Combine

@MainActor
final class ProductListViewModel {
    @Published private(set) var products: [Product] = []

    var onProductsChanged: (([Product]) -> Void)?

    func loadProducts() {
        products = [
            Product(id: 1, name: "Keyboard", price: 1499),
            Product(id: 2, name: "Mouse", price: 799),
            Product(id: 3, name: "Headphones", price: 2499)
        ]

        onProductsChanged?(products)
    }

    func formattedPrice(for product: Product) -> String {
        product.price.formatted(
            .currency(code: "INR")
        )
    }
}

This example exposes product state through @Published and also uses a closure to demonstrate a simple UIKit callback. In a production app, choose a single clear observation mechanism where possible. You can instead subscribe to the published property with Combine, as shown below.

6.3 Connect the ViewModel to a UIViewController

import UIKit
import Combine

@MainActor
final class ProductListViewController: UIViewController {
    private let viewModel = ProductListViewModel()
    private var cancellables = Set<AnyCancellable>()

    private let tableView = UITableView(
        frame: .zero,
        style: .plain
    )

    private var products: [Product] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        title = "Products"
        view.backgroundColor = .systemBackground
        configureTableView()
        bindViewModel()
        viewModel.loadProducts()
    }

    private func configureTableView() {
        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(
                equalTo: view.safeAreaLayoutGuide.topAnchor
            ),
            tableView.leadingAnchor.constraint(
                equalTo: view.leadingAnchor
            ),
            tableView.trailingAnchor.constraint(
                equalTo: view.trailingAnchor
            ),
            tableView.bottomAnchor.constraint(
                equalTo: view.bottomAnchor
            )
        ])

        tableView.dataSource = self
        tableView.register(
            UITableViewCell.self,
            forCellReuseIdentifier: "ProductCell"
        )
    }

    private func bindViewModel() {
        viewModel.$products
            .receive(on: DispatchQueue.main)
            .sink { [weak self] products in
                self?.products = products
                self?.tableView.reloadData()
            }
            .store(in: &cancellables)
    }
}

extension ProductListViewController: UITableViewDataSource {
    func tableView(
        _ tableView: UITableView,
        numberOfRowsInSection section: Int
    ) -> Int {
        products.count
    }

    func tableView(
        _ tableView: UITableView,
        cellForRowAt indexPath: IndexPath
    ) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(
            withIdentifier: "ProductCell",
            for: indexPath
        )

        let product = products[indexPath.row]
        var content = cell.defaultContentConfiguration()
        content.text = product.name
        content.secondaryText = viewModel.formattedPrice(
            for: product
        )
        cell.contentConfiguration = content

        return cell
    }
}

How this works: the controller configures the table view, subscribes to the ViewModel's published product array, and reloads the table when the array changes. The ViewModel does not need to know that a UITableView exists.

6.4 Alternative: Closure-Based Binding

Closures can be useful for simple one-screen examples or when the ViewModel exposes a small number of events. If you use a closure, capture the view controller weakly to avoid a retain cycle.

viewModel.onProductsChanged = { [weak self] products in
    guard let self else { return }
    self.products = products
    self.tableView.reloadData()
}

viewModel.loadProducts()

Do not bind the same state through both this closure and the Combine subscription unless there is a specific reason. Duplicate bindings can cause repeated UI updates and confusing behavior.

7. SwiftUI MVVM vs. UIKit MVVM

Area SwiftUI UIKit
UI creation Declarative views View controllers and views
State updates Observation or Combine integration Explicit binding through Combine, closures, delegates, or callbacks
UI refresh Driven by state changes Often requires table reloads, collection updates, or view configuration
ViewModel ownership Typically @State for @Observable or @StateObject for ObservableObject Often a stored property or dependency injected by a coordinator or parent
Data binding Built-in binding syntax such as $viewModel.name Usually explicit event handlers and observation
Testing ViewModel logic can be tested separately from the view ViewModel logic can be tested separately from the view controller

The architectural principle remains the same across both frameworks. The biggest difference is how each framework creates the interface and observes changes in state.

8. Adding a Service Layer

In a real application, the ViewModel should not usually contain all networking or persistence code. A service or repository can provide data and keep external dependencies separate from presentation logic.

import Foundation

protocol UserService {
    func fetchUsers() async throws -> [User]
}

struct APIUserService: UserService {
    func fetchUsers() async throws -> [User] {
        let url = URL(
            string: "https://jsonplaceholder.typicode.com/users"
        )!

        let (data, response) = try await URLSession.shared.data(
            from: url
        )

        guard let response = response as? HTTPURLResponse,
              (200...299).contains(response.statusCode) else {
            throw URLError(.badServerResponse)
        }

        return try JSONDecoder().decode([User].self, from: data)
    }
}

The URL above is a sample public API endpoint. In a real project, define endpoint configuration carefully, handle authentication when needed, and avoid force-unwrapping URLs derived from external input.

8.1 ViewModel with Loading and Error States

import Foundation
import Observation

@MainActor
@Observable
final class UserListViewModel {
    private let service: UserService

    private(set) var users: [User] = []
    private(set) var isLoading = false
    var errorMessage: String?

    init(service: UserService) {
        self.service = service
    }

    func loadUsers() async {
        guard !isLoading else { return }

        isLoading = true
        errorMessage = nil
        defer { isLoading = false }

        do {
            users = try await service.fetchUsers()
        } catch {
            errorMessage = "Unable to load users. Please try again."
        }
    }
}

Dependency injection makes the ViewModel easier to test because the service can be replaced with a fake implementation. The ViewModel also exposes loading and error state instead of making the view infer those states from the network request.

8.2 SwiftUI View for Asynchronous User Data

import SwiftUI

struct UserListScreen: View {
    @State private var viewModel: UserListViewModel

    init(service: UserService = APIUserService()) {
        _viewModel = State(
            initialValue: UserListViewModel(service: service)
        )
    }

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView("Loading users...")
                } else if let message = viewModel.errorMessage {
                    VStack(spacing: 12) {
                        Text(message)
                        Button("Retry") {
                            Task {
                                await viewModel.loadUsers()
                            }
                        }
                    }
                } else {
                    List(viewModel.users) { user in
                        VStack(alignment: .leading) {
                            Text(user.name)
                                .font(.headline)
                            Text(user.email)
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }
                    }
                }
            }
            .navigationTitle("Users")
            .task {
                if viewModel.users.isEmpty {
                    await viewModel.loadUsers()
                }
            }
        }
    }
}

This screen demonstrates the common loading, success, and error states. In larger applications, consider representing screen state with an enum such as idle, loading, loaded, and failed when that makes invalid combinations of state harder to express.

9. Unit Testing a ViewModel

MVVM makes it possible to test most presentation logic without launching the UI. A test can inject a fake service and verify that the ViewModel updates its state as expected.

import XCTest
@testable import YourAppModule

struct FakeUserService: UserService {
    let result: [User]

    func fetchUsers() async throws -> [User] {
        result
    }
}

@MainActor
final class UserListViewModelTests: XCTestCase {
    func testLoadUsersStoresReturnedUsers() async {
        let expected = [
            User(id: 1, name: "Sam", email: "[email protected]")
        ]
        let service = FakeUserService(result: expected)
        let viewModel = UserListViewModel(service: service)

        await viewModel.loadUsers()

        XCTAssertEqual(viewModel.users.count, 1)
        XCTAssertEqual(viewModel.users.first?.name, "Sam")
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertNil(viewModel.errorMessage)
    }
}

Replace YourAppModule with the actual module name in your project. Depending on your Swift version and test configuration, you may need to adapt actor isolation or test setup.

Useful Test Cases

  • Verify that valid input creates a model or triggers the intended action.
  • Verify that empty or invalid input is rejected.
  • Verify that successful service responses update the ViewModel's state.
  • Verify that failed requests produce an appropriate error state.
  • Verify that loading state returns to false after success or failure.
  • Verify that retrying a failed operation can succeed.

10. Best Practices for MVVM in iOS

  • Keep Views focused: avoid putting networking, complex validation, and data transformation directly into the UI.
  • Keep ViewModels UI-framework independent: avoid importing UIKit or SwiftUI into ViewModels unless there is a clear, justified need.
  • Use dependency injection: pass services or repositories into ViewModels rather than hard-coding concrete dependencies.
  • Keep state coherent: represent loading, success, empty, and failure states clearly.
  • Use main-actor isolation for UI-facing state: avoid updating UI-observed state from an inappropriate execution context.
  • Avoid oversized ViewModels: separate responsibilities when a ViewModel begins handling many unrelated features.
  • Prefer meaningful names: use names such as LoginViewModel and OrderHistoryViewModel.
  • Test business and presentation logic: use fake services to test successful and failing scenarios.
  • Avoid unnecessary abstractions: introduce protocols and repositories when they improve flexibility, clarity, or testability.
  • Do not confuse MVVM with a strict rulebook: adapt the pattern to the scale and needs of the application.

11. Common Mistakes and Troubleshooting

Problem Likely Cause Solution
SwiftUI does not refresh The property is not observed, or the wrong observation pattern is used Check whether the model uses @Observable or ObservableObject and use the matching ownership and observation mechanism.
UIKit list remains unchanged The controller does not respond to ViewModel changes Bind a publisher, closure, or delegate and update the table or collection view.
ViewModel is difficult to test It creates network services internally or relies on global state Inject dependencies through its initializer.
Repeated network requests Loading is triggered repeatedly by lifecycle or rendering events Choose a clear request trigger, manage task lifetime, and consider caching or request deduplication.
Memory leaks in UIKit A closure or Combine subscription strongly retains its owner Use weak captures where appropriate and manage cancellables for the correct lifetime.
UI state changes off the main actor Concurrency and actor isolation are not handled correctly Use appropriate actor isolation and keep UI-facing state updates on the main actor.
ViewModel becomes too large Networking, formatting, navigation, and unrelated features are mixed together Extract focused services, repositories, or child ViewModels where this reduces complexity.

12. Practical Project: Product Catalog Application

Build a small product catalog using MVVM. Implement the interface in either SwiftUI or UIKit while reusing the same Model and service layer where practical.

Suggested Features

  • Display a list of products with names and prices.
  • Fetch products from an API or a local mock service.
  • Show a loading indicator while data is being retrieved.
  • Display an empty state when no products are available.
  • Show an understandable error message when loading fails.
  • Provide a retry button.
  • Add search and category filtering through ViewModel methods.
  • Write unit tests for loading, filtering, and error handling.

Suggested Folder Structure

ProductCatalog/
├── Models/
│   └── Product.swift
├── Services/
│   ├── ProductService.swift
│   └── APIProductService.swift
├── ViewModels/
│   └── ProductListViewModel.swift
├── Views/
│   ├── SwiftUI/
│   │   └── ProductListView.swift
│   └── UIKit/
│       └── ProductListViewController.swift
└── Tests/
    └── ProductListViewModelTests.swift

This structure is a starting point, not a mandatory layout. Organize files according to the size of the project and the conventions of the team.

13. Practice Exercises

  1. Create a login screen with LoginViewModel and validate email and password input.
  2. Build a notes list where the ViewModel adds, edits, deletes, and filters notes.
  3. Implement the same product list using SwiftUI and UIKit while sharing the data model.
  4. Add a fake service that simulates a network failure and test the retry flow.
  5. Refactor a view controller that contains networking and data parsing into a ViewModel and service.
  6. Write unit tests for loading, empty results, validation, and error states.

14. Interview Questions and Answers

Q1. What is MVVM?

MVVM is an architectural pattern that separates data and domain concepts, UI presentation, and presentation logic into Model, View, and ViewModel components.

Q2. What is the responsibility of a ViewModel?

A ViewModel prepares state for the View, processes UI actions, coordinates services, and exposes results or errors in a form the View can present.

Q3. Can MVVM be used with UIKit?

Yes. UIKit can use ViewModels with Combine, closures, delegates, or other binding techniques. The view controller observes state and updates the interface.

Q4. How does SwiftUI observe a ViewModel?

SwiftUI can observe a class using the Observation framework's @Observable macro or the Combine-based ObservableObject protocol. The correct property wrappers depend on which approach is used and who owns the object.

Q5. What is dependency injection?

Dependency injection means providing an object with the services or collaborators it needs, often through an initializer. This makes components easier to replace, configure, and test.

Q6. Why should networking usually be outside the View?

Separating networking from presentation keeps UI code simpler, supports reuse, centralizes request handling, and makes the application's behavior easier to test.

Q7. What is the difference between a Model and a ViewModel?

A Model represents domain data or concepts. A ViewModel prepares state and actions for the UI and coordinates work needed to display or modify that data.

Q8. How do you test a ViewModel?

Instantiate the ViewModel in a unit test, inject a fake or mock service, call its methods, and assert that its state and outputs match expectations.

Q9. Does MVVM require a repository?

No. A repository is an optional supporting abstraction. It is useful when it isolates data sources or improves testability, but it is not mandatory for every small screen.

Q10. What is a common MVVM mistake?

A common mistake is moving all logic into one very large ViewModel. The goal is separation of responsibilities, not simply relocating a massive view controller into another class.

15. Quick Revision Checklist

  • Understand the Model, View, and ViewModel roles.
  • Keep presentation code separate from data access.
  • Use the appropriate SwiftUI observation strategy.
  • Bind UIKit views to ViewModel changes explicitly.
  • Manage loading, success, empty, and failure states.
  • Inject services to improve flexibility and testing.
  • Test ViewModel behavior independently of the UI.
  • Handle asynchronous work and actor isolation correctly.
  • Keep each component focused on a clear responsibility.

16. Conclusion

MVVM is a practical architecture for organizing iOS applications built with SwiftUI and UIKit. The Model represents the data, the View presents the interface, and the ViewModel coordinates presentation state and user actions. SwiftUI provides declarative state-driven updates, while UIKit usually needs explicit bindings through Combine, closures, or delegates.

By separating UI code from data access, injecting dependencies, handling asynchronous states carefully, and testing ViewModels independently, developers can build iOS applications that are easier to maintain and extend.

Additional Learning Resources

Previous Next
whatsapp