```html SwiftUI: How to Implement MVVM Architecture (iOS 17+, 2026)

How to Implement MVVM Architecture in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: ObservableObject / Observable Updated: May 11, 2026
TL;DR

Annotate your ViewModel class with @Observable (iOS 17+) and declare it as @State in your root view — SwiftUI automatically re-renders only the views that read changed properties. For pre-iOS 17 targets, use ObservableObject with @Published and @StateObject instead.

import Observation
import SwiftUI

@Observable
final class CounterViewModel {
    var count = 0
    func increment() { count += 1 }
}

struct CounterView: View {
    @State private var vm = CounterViewModel()
    var body: some View {
        Button("Count: \(vm.count)") { vm.increment() }
    }
}

Full implementation

The example below models a simple task list app. The TaskViewModel owns the array of tasks and exposes intent methods (add, toggle, delete) so the view never mutates data directly. Because @Observable does fine-grained tracking, only the rows that actually changed will re-render — no unnecessary diffs.

import Observation
import SwiftUI

// MARK: – Model

struct Task: Identifiable {
    let id: UUID
    var title: String
    var isDone: Bool

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

// MARK: – ViewModel

@Observable
final class TaskViewModel {
    var tasks: [Task] = [
        Task(title: "Design onboarding screens"),
        Task(title: "Wire up SwiftData model"),
        Task(title: "Submit to TestFlight"),
    ]
    var newTaskTitle: String = ""
    var errorMessage: String? = nil

    var completedCount: Int { tasks.filter(\.isDone).count }

    func addTask() {
        let trimmed = newTaskTitle.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else {
            errorMessage = "Title can't be empty."
            return
        }
        tasks.append(Task(title: trimmed))
        newTaskTitle = ""
        errorMessage = nil
    }

    func toggleTask(_ task: Task) {
        guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
        tasks[idx].isDone.toggle()
    }

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

// MARK: – View

struct TaskListView: View {
    @State private var vm = TaskViewModel()

    var body: some View {
        NavigationStack {
            List {
                Section {
                    ForEach(vm.tasks) { task in
                        TaskRow(task: task) { vm.toggleTask(task) }
                    }
                    .onDelete(perform: vm.deleteTasks)
                } header: {
                    Text("\(vm.completedCount) of \(vm.tasks.count) done")
                        .font(.caption)
                }
            }
            .navigationTitle("Tasks")
            .toolbar {
                ToolbarItem(placement: .bottomBar) {
                    AddTaskBar(title: $vm.newTaskTitle,
                               error: vm.errorMessage,
                               onAdd: vm.addTask)
                }
            }
        }
    }
}

// MARK: – Sub-views

struct TaskRow: View {
    let task: Task
    let onToggle: () -> Void

    var body: some View {
        Button(action: onToggle) {
            Label {
                Text(task.title)
                    .strikethrough(task.isDone, color: .secondary)
                    .foregroundStyle(task.isDone ? .secondary : .primary)
            } icon: {
                Image(systemName: task.isDone ? "checkmark.circle.fill" : "circle")
                    .foregroundStyle(task.isDone ? .green : .secondary)
            }
        }
        .buttonStyle(.plain)
        .accessibilityLabel("\(task.title), \(task.isDone ? "completed" : "incomplete")")
        .accessibilityHint("Double-tap to toggle")
    }
}

struct AddTaskBar: View {
    @Binding var title: String
    let error: String?
    let onAdd: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            if let error {
                Text(error).font(.caption).foregroundStyle(.red)
            }
            HStack {
                TextField("New task…", text: $title)
                    .textFieldStyle(.roundedBorder)
                    .submitLabel(.done)
                    .onSubmit(onAdd)
                Button("Add", action: onAdd)
                    .buttonStyle(.borderedProminent)
            }
        }
        .padding(.bottom, 8)
    }
}

// MARK: – Preview

#Preview {
    TaskListView()
}

How it works

  1. @Observable macro (Observation framework) — Applied to TaskViewModel, it instruments each stored property so the Swift runtime records which properties a view body accesses. Only those specific properties trigger a re-render when they change, avoiding the "all @Published properties invalidate everything" problem of the older ObservableObject approach.
  2. @State private var vm = TaskViewModel() — Declaring the ViewModel as @State in the root view gives SwiftUI ownership of its lifetime. The view creates exactly one instance and keeps it alive across re-renders. Do not use a plain let or var — those are recreated on every body evaluation.
  3. Intent methods over direct mutationaddTask(), toggleTask(_:), and deleteTasks(at:) are the only paths to state change. The view calls these methods; it never writes to vm.tasks directly. This keeps the ViewModel testable in isolation.
  4. @Binding var title in AddTaskBar — Child views that need two-way access to a single ViewModel property receive a Binding (e.g., $vm.newTaskTitle). This preserves a single source of truth without passing the entire ViewModel down.
  5. Computed property completedCount — Derived state lives in the ViewModel, not the view. Because @Observable tracks tasks, any mutation to the array automatically invalidates callers of completedCount at no extra cost.

Variants

Sharing a ViewModel across the environment

When multiple unrelated views deep in the hierarchy need the same ViewModel, inject it via the SwiftUI environment instead of prop-drilling.

// Root view — inject into environment
struct RootApp: App {
    @State private var vm = TaskViewModel()

    var body: some Scene {
        WindowGroup {
            TaskListView()
                .environment(vm)   // No key needed with @Observable
        }
    }
}

// Any descendant view — read from environment
struct TaskBadgeView: View {
    @Environment(TaskViewModel.self) private var vm

    var body: some View {
        if vm.completedCount < vm.tasks.count {
            Text("\(vm.tasks.count - vm.completedCount) remaining")
                .badge(vm.tasks.count - vm.completedCount)
        }
    }
}

iOS 16 fallback with ObservableObject

If your deployment target is iOS 16 or earlier, replace @Observable with ObservableObject, mark each published property with @Published, and swap @State for @StateObject at the call site. The architecture remains identical — only the observation mechanism changes. Computed properties and intent methods require no modification.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement MVVM architecture in SwiftUI for iOS 17+.
Use @Observable (Observation framework) for the ViewModel.
Expose intent methods for all state mutations — no direct
property writes from the View layer.
Make it accessible (VoiceOver labels on interactive elements).
Add a #Preview with realistic sample data.

In Soarias, drop this prompt into the Build phase after your screen mockups are locked — Claude Code will scaffold the full ViewModel layer and wire it to your existing View files without touching your model types.

Related

FAQ

Does this work on iOS 16?

The @Observable macro requires iOS 17+. For iOS 16 support, use ObservableObject with @Published on each property and @StateObject in the view. The MVVM structure and intent-method pattern are identical; only the observation plumbing differs. You can conditionally compile with #if swift(>=5.9) to share a single file if you must support both OS versions.

Should the ViewModel own networking / persistence, or should those live elsewhere?

In small apps it's fine to call an async service directly from a ViewModel method. As the app grows, extract a repository or service layer — plain Swift actors or structs that the ViewModel depends on via protocol. This keeps the ViewModel's init testable with mock dependencies and prevents it from ballooning into a "Massive ViewModel." SwiftData ModelContext is a good example: inject it into the ViewModel via init rather than accessing the shared container directly.

What's the UIKit equivalent?

UIKit MVVM typically uses Combine (PassthroughSubject / CurrentValueSubject) or closure callbacks to push ViewModel changes into the ViewController. The ViewController subscribes in viewDidLoad and updates UILabel / UITableView imperatively. SwiftUI eliminates this boilerplate entirely — the @Observable runtime and the view body act as the binding layer, so there's no need to manually manage AnyCancellable subscriptions or reloadData() calls.

Last reviewed: 2026-05-11 by the Soarias team.

```