How to Implement MVVM Architecture in SwiftUI
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
-
@Observablemacro (Observation framework) — Applied toTaskViewModel, 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@Publishedproperties invalidate everything" problem of the olderObservableObjectapproach. -
@State private var vm = TaskViewModel()— Declaring the ViewModel as@Statein 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 plainletorvar— those are recreated on every body evaluation. -
Intent methods over direct mutation —
addTask(),toggleTask(_:), anddeleteTasks(at:)are the only paths to state change. The view calls these methods; it never writes tovm.tasksdirectly. This keeps the ViewModel testable in isolation. -
@Binding var titleinAddTaskBar— Child views that need two-way access to a single ViewModel property receive aBinding(e.g.,$vm.newTaskTitle). This preserves a single source of truth without passing the entire ViewModel down. -
Computed property
completedCount— Derived state lives in the ViewModel, not the view. Because@Observabletrackstasks, any mutation to the array automatically invalidates callers ofcompletedCountat 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
-
@Observablerequires iOS 17 / macOS 14. If your minimum deployment target is lower, the compiler will silently fall back — or crash at runtime on older OS versions. Use#available(iOS 17, *)guards or adopt theObservableObjectvariant for shared codebases. -
Don't use
@StateObjectwith@Observable.@StateObjectis forObservableObject-conforming classes only. Mixing the two compiles but defeats fine-grained observation — SwiftUI falls back to invalidating the whole body. Use@Statefor@ObservableViewModels. -
Passing a ViewModel as a plain parameter loses reactivity. If you pass an
@Observableobject as a plainletargument to a child view, the child won't re-render when the object changes. Pass it as@Bindablefor two-way bindings, or via.environment(_:)for shared read access. -
Don't put navigation state in a global ViewModel. Navigation path, sheet presentation, and alert state belong in
@Stateon the View or a dedicated coordinator, not the domain ViewModel. Mixing them couples your business logic to presentation and makes deep-linking painful.
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.