Back to Articles
ArchitectureSwiftUIMVVMArchitecture

Getting Started with SwiftUI and MVVM Architecture

10 min read

Getting Started with SwiftUI and MVVM Architecture

SwiftUI has revolutionized iOS development with its declarative syntax and powerful features. When combined with the MVVM (Model-View-ViewModel) architecture pattern, you can build highly maintainable and testable applications.

Why MVVM with SwiftUI?

MVVM is particularly well-suited for SwiftUI because of its reactive nature. The ViewModel acts as a bridge between your Model (data) and View (UI), handling business logic and state management.

Key Benefits:

  • Separation of Concerns: Clear separation between UI and business logic
  • Testability: ViewModels can be tested independently
  • Reusability: ViewModels can be shared across multiple views
  • Maintainability: Easier to maintain and scale your codebase
  • SwiftUI Integration: Works seamlessly with @Published and ObservableObject

    Understanding the MVVM Components

    Model

  • The Model represents your data structures and business entities. It should be pure data with no UI dependencies.

    swift
    // Model 
  • Pure data structures
struct User: Identifiable, Codable { let id: UUID let name: String let email: String let avatarURL: URL? let joinDate: Date

var displayName: String { name.isEmpty ? "Anonymous" : name } }

struct Post: Identifiable, Codable { let id: UUID let userId: UUID let title: String let content: String let createdAt: Date let likes: Int }

ViewModel

The ViewModel contains presentation logic and acts as the intermediary between Model and View.

swift
// ViewModel 
  • Presentation logic and state management
  • @MainActor class UserViewModel: ObservableObject { // Published properties that Views can observe @Published var users: [User] = [] @Published var isLoading = false @Published var errorMessage: String? @Published var searchText = ""

    // Dependencies injected for testability private let userService: UserServiceProtocol private let analytics: AnalyticsService

    init(userService: UserServiceProtocol = UserService(), analytics: AnalyticsService = AnalyticsService.shared) { self.userService = userService self.analytics = analytics }

    // Computed property for filtered results var filteredUsers: [User] { guard !searchText.isEmpty else { return users } return users.filter { $0.name.localizedCaseInsensitiveContains(searchText) } }

    // Async operation with error handling func fetchUsers() async { isLoading = true errorMessage = nil

    do { users = try await userService.getUsers() analytics.logEvent("users_fetched", parameters: ["count": users.count]) } catch { errorMessage = "Failed to load users: \(error.localizedDescription)" analytics.logError(error) }

    isLoading = false }

    // User actions func deleteUser(_ user: User) async throws { try await userService.deleteUser(id: user.id) users.removeAll { $0.id == user.id } }

    func refreshData() async { await fetchUsers() } }

View

The View is purely declarative and observes the ViewModel.

swift
// View 
  • Pure UI with no business logic
  • struct UserListView: View { @StateObject private var viewModel = UserViewModel() @State private var selectedUser: User?

    var body: some View { NavigationStack { ZStack { if viewModel.isLoading { ProgressView("Loading users...") } else { userListContent } } .navigationTitle("Users") .searchable(text: $viewModel.searchText) .refreshable { await viewModel.refreshData() } .task { await viewModel.fetchUsers() } .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { Button("OK") { viewModel.errorMessage = nil } } message: { if let error = viewModel.errorMessage { Text(error) } } } }

    private var userListContent: some View { List { ForEach(viewModel.filteredUsers) { user in UserRow(user: user) .onTapGesture { selectedUser = user } .swipeActions { Button(role: .destructive) { Task { try? await viewModel.deleteUser(user) } } label: { Label("Delete", systemImage: "trash") } } } } .sheet(item: $selectedUser) { user in UserDetailView(user: user) } } }

    struct UserRow: View { let user: User

    var body: some View { HStack { AsyncImage(url: user.avatarURL) { image in image.resizable() } placeholder: { Color.gray } .frame(width: 50, height: 50) .clipShape(Circle())

    VStack(alignment: .leading) { Text(user.displayName) .font(.headline) Text(user.email) .font(.subheadline) .foregroundColor(.secondary) } } } }

    Advanced MVVM Patterns

    1. Dependency Injection for Testing

    swift
    // Protocol for dependency injection
    protocol UserServiceProtocol {
        func getUsers() async throws -> [User]
        func deleteUser(id: UUID) async throws
    }

    // Production implementation class UserService: UserServiceProtocol { func getUsers() async throws -> [User] { let url = URL(string: "https://api.example.com/users")! let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode([User].self, from: data) }

    func deleteUser(id: UUID) async throws { let url = URL(string: "https://api.example.com/users/\(id)")! var request = URLRequest(url: url) request.httpMethod = "DELETE" _ = try await URLSession.shared.data(for: request) } }

    // Mock for testing class MockUserService: UserServiceProtocol { var mockUsers: [User] = [] var shouldThrowError = false

    func getUsers() async throws -> [User] { if shouldThrowError { throw NSError(domain: "test", code: -1) } return mockUsers }

    func deleteUser(id: UUID) async throws { mockUsers.removeAll { $0.id == id } } }

    2. State Management with Combine

    swift
    import Combine

    @MainActor class SearchViewModel: ObservableObject { @Published var searchQuery = "" @Published var searchResults: [User] = [] @Published var isSearching = false

    private var cancellables = Set() private let searchService: SearchService

    init(searchService: SearchService = SearchService()) { self.searchService = searchService setupSearchPipeline() }

    private func setupSearchPipeline() { $searchQuery .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .removeDuplicates() .filter { !$0.isEmpty } .sink { [weak self] query in Task { await self?.performSearch(query: query) } } .store(in: &cancellables) }

    private func performSearch(query: String) async { isSearching = true defer { isSearching = false }

    do { searchResults = try await searchService.search(query: query) } catch { searchResults = [] } } }

    3. Handling Complex State

    swift
    @MainActor
    class PostViewModel: ObservableObject {
        enum LoadingState {
            case idle
            case loading
            case loaded([Post])
            case error(Error)
        }

    @Published var state: LoadingState = .idle

    func loadPosts() async { state = .loading

    do { let posts = try await PostService.shared.fetchPosts() state = .loaded(posts) } catch { state = .error(error) } } }

    struct PostListView: View { @StateObject private var viewModel = PostViewModel()

    var body: some View { Group { switch viewModel.state { case .idle: Color.clear .task { await viewModel.loadPosts() } case .loading: ProgressView() case .loaded(let posts): List(posts) { post in PostRow(post: post) } case .error(let error): ErrorView(error: error) { Task { await viewModel.loadPosts() } } } } } }

    Best Practices

    1. Keep ViewModels Focused

    Each ViewModel should have a single responsibility. Don't create massive "God" ViewModels.

    2. Use @MainActor

    Mark ViewModels with @MainActor to ensure UI updates happen on the main thread.

    3. Avoid View Logic in ViewModels

    Don't import SwiftUI in ViewModels. Keep them UI-agnostic for better testability.

    4. Proper Error Handling

    Always handle errors gracefully and provide meaningful messages to users.

    5. Memory Management

    Use [weak self] in closures to avoid retain cycles.

    swift
    timer.sink { [weak self] _ in
        self?.updateData()
    }
    

    Testing ViewModels

    swift
    @MainActor
    final class UserViewModelTests: XCTestCase {
        var sut: UserViewModel!
        var mockService: MockUserService!

    override func setUp() { super.setUp() mockService = MockUserService() sut = UserViewModel(userService: mockService) }

    func testFetchUsersSuccess() async { // Given let expectedUsers = [ User(id: UUID(), name: "John", email: "john@example.com", avatarURL: nil, joinDate: Date()) ] mockService.mockUsers = expectedUsers

    // When await sut.fetchUsers()

    // Then XCTAssertEqual(sut.users.count, 1) XCTAssertEqual(sut.users.first?.name, "John") XCTAssertFalse(sut.isLoading) XCTAssertNil(sut.errorMessage) }

    func testFetchUsersError() async { // Given mockService.shouldThrowError = true

    // When await sut.fetchUsers()

    // Then XCTAssertTrue(sut.users.isEmpty) XCTAssertNotNil(sut.errorMessage) XCTAssertFalse(sut.isLoading) } }

    Common Pitfalls to Avoid

    1. Putting UI Code in ViewModels: Keep ViewModels UI-agnostic 2. Not Using Dependency Injection: Makes testing difficult 3. Forgetting @MainActor: Can cause UI update crashes 4. Overusing @Published: Only publish what Views actually observe 5. Creating Retain Cycles: Always use [weak self] in closures

    Conclusion

    MVVM with SwiftUI provides a robust architecture for building maintainable iOS applications. By following these patterns and best practices, you can create apps that are easy to test, maintain, and scale.

    Remember: Start simple, add complexity only when needed, and always prioritize code readability and testability.

    Happy coding! 🚀