Is SwiftData incompatible with MVVM? The standard answer disregards some key principles driving SwiftUI’s architecture

17 min read Original article ↗

Some developers claim that MVVM is incompatible with SwiftUI.

However, with a proper understanding of SwiftUI, it is possible to address any criticisms and eliminate the boilerplate code seen in many online blogs.

In this article, we will explore some fundamental yet ignored SwiftUI features to understand how to replicate its integration with SwiftData inside our view models.

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices

MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.

DOWNLOAD THE FREE GUIDE

Table of contents

  1. Why you should consider MVVM for your SwiftUI apps using SwiftData for storage
  2. The interoperability of SwiftData and SwiftUI can be easily replicated in your code
  3. Moving the SwiftData access layer logic into a view model and verifying it in a unit test
  4. The wrong MVVM implementation repeated in many online articles
  5. @Query properties are not sources of truth
  6. The cumbersome instantiation pattern of view models that require the model context to be injected through their initializer
  7. The shared model context is available to any dynamic property, not just @Query properties
  8. View models don’t need to update their stored properties after every change in the model context

Why you should consider MVVM for your SwiftUI apps using SwiftData for storage

MVVM is not a mandatory design pattern in SwiftUI. However, it has several benefits, as I detailed in my article on view models in SwiftUI, like:

  • Separation of concerns and code readability.
  • Modularity and testability.
  • Following fundamental software design principles, e.g., SOLID.

Some developers seem to take issue with MVVM in SwiftUI apps, especially when using SwiftData for storage.

Looking at the top Google results, you can find some articles, including ones coming from experienced developers, decrying the shortcomings of pairing SwiftData with MVVM.

Unfortunately, these often provide suboptimal examples; therefore, I’ll spend the rest of this article addressing those complaints and presenting a better approach to the pattern.

Best practice

As a developer, it is your duty to think critically and never take anything you read online as the absolute truth, even when it comes from developers allegedly far more experienced than you, and even if it seems to be the consensus.

Any argument must be assessed on its own merits and critically challenged. This rule also applies to anything I write, including the article you are reading. In fact, this article underwent several corrections after developers pointed out flaws in my code.

Moreover, Google is never the arbiter of what is true, and you must take anything you find in the top results with a grain of salt, even when it is repeated by many, especially in this age of thoughtless AI-slop.

The interoperability of SwiftData and SwiftUI can be easily replicated in your code

Most complaints about MVVM with SwiftData stem from the mistaken belief that SwiftUI and SwiftData are inextricably intertwined.

You can find many examples of such a belief online, like the most-voted comment on this Reddit post.

Leaving aside the other claims made by the post for now, the claim that the two frameworks are “designed to be coupled” has no basis in reality. SwiftData is an independent framework that can be used with SwiftUI, UIKit, and AppKit.

In fact, the connections between SwiftData and SwiftUI are pretty thin, even though they are certainly convenient. They boil down to:

  • The Query() macro.
  • The modelContext environment value.
  • The relative modelContainer(_:) and modelContext(_:) instance methods on the Scene and View protocols.

That’s it. While these connections make it easier to use SwiftData in SwiftUI, none are actually necessary for its functionality.

Moving the SwiftData access layer logic into a view model and verifying it in a unit test

I will use, as a starting example, the template code provided by Xcode when creating a new project with SwiftData for storage.

You can find the complete project on GitHub.

All the relevant SwiftData code is in the ContentView.swift file.

ContentView.swift

struct ContentView: View {
	@Environment(\.modelContext) private var modelContext
	@Query private var items: [Item]

	var body: some View {
		NavigationSplitView {
			List {
				ForEach(items) { item in
					// ...
				}
				.onDelete(perform: deleteItems)
			}
			.toolbar {
				// ...
				ToolbarItem {
					Button(action: addItem) {
						// ...
					}
				}
			}
		} detail: {
			// ...
		}
	}

	private func addItem() {
		withAnimation {
			let newItem = Item(timestamp: Date())
			modelContext.insert(newItem)
		}
	}

	private func deleteItems(offsets: IndexSet) {
		withAnimation {
			for index in offsets {
				modelContext.delete(items[index])
			}
		}
	}
}

Creating a view model for this view requires moving the code implementing the app’s business logic related to the data access layer into a separate class. We can start with the addItem() method.

ViewModel.swift

@Observable class ViewModel {
	private let modelContext: ModelContext

	init(modelContext: ModelContext) {
		self.modelContext = modelContext
	}

	func addItem() {
		let newItem = Item(timestamp: Date())
		modelContext.insert(newItem)
	}
}

This has the immediate benefit of making our code encapsulated) and testable, which was not possible when it was embedded in the view.

SwiftDataMVVMTests.swift

@Test func viewModelInsert() async throws {
	let container = ModelContainer.modelContainer(for: Item.self, inMemory: true)
	let context = container.mainContext
	let viewModel = ViewModel(modelContext: context)
	viewModel.addItem()
	let items: [Item] = try context.fetch(.init())
	#expect(items.count == 1)
}
Note

The ModelContainer class cannot be mocked through subclassing because it’s not declared as open by the SwiftData framework. Attempting to do so will result in a compiler error.

// error: Cannot inherit from non-open class 'ModelContext' outside
// of its defining module
class Mock: ModelContext {

}

Most unit tests for SwiftData code can be performed using an in-memory model context, as shown above.

If you need a mock object, you must use a protocol listing, as requirements, the portion of the ModelContext class interface used by the view model.

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices

MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.

DOWNLOAD THE FREE GUIDE

The wrong MVVM implementation repeated in many online articles

The code I just showed above is fairly straightforward, and I don’t think anyone would have any issues with it so far. However, the complaints arise as soon as the view model requires an array of items, such as the one in the @Query property of the ContentView.

In our example, that happens when we try to move into the view model the deleteItems(offsets:) method, which references the items property of the ContentView.

Many developers erroneously believe they need to turn the items property into a stored property and complain that such a property must then be updated every time the model context changes, since the @Query macro can be used only inside SwiftUI views.

ViewModel.swift

@Observable class ViewModel {
	var items: [Item] = [] // This should not be a stored property
	private let modelContext: ModelContext

	init(modelContext: ModelContext) {
		self.modelContext = modelContext
		update() // This is unnecessary
	}

	func addItem() {
		let newItem = Item(timestamp: Date())
		modelContext.insert(newItem)
		update() // This is unnecessary
	}

	func deleteItems(offsets: IndexSet) {
		for index in offsets {
			modelContext.delete(items[index])
		}
		update() // This is unnecessary
	}

	// This is unnecessary
	private func update() {
		items = (try? modelContext.fetch(FetchDescriptor())) ?? []
	}
}

Critics of MVVM then dismiss the pattern because of their own poor implementation, blaming it for introducing all the above boilerplate code just to keep a single stored property up to date.

If that were the correct way of implementing MVVM, I would agree. The code above is redundant and error-prone, as it’s easy to forget to call the update() method at the appropriate times.

It is also incorrect because it does not respond to changes in the model context caused by code outside the view model, which can lead to subtle and hard-to-find bugs.

@Query properties are not sources of truth

The mistake in the above implementation is believing that a @Query property is a single source of truth, as if it were a @State property, that must be moved into the view model.

However, expanding the @Query macro in Xcode reveals that the attached stored property gets transformed into a read-only computed property.

We will focus on the generated private stored property later.

What is important here is that the contents of the items property cannot be modified, even though you can update each Item object independently, because the class has an Observable conformance added by the Model() macro.

Misconception

A @Query property does not establish a new single source of truth in a SwiftUI view. Instead, it provides read-only access to the underlying model context, which is the real single source of truth for all data stored by SwiftData.

As such, @Query properties do not need to be moved inside a view model. Instead, the view model can access the model context independently, while the original @Query property can keep driving the user interface updates in the SwiftUI view.

This means that, if our ViewModel class needs to access the array of items, it can do so by implementing its own computed property that accesses the underlying model context, rather than a stored property that requires constant updates.

ViewModel.swift

@Observable class ViewModel {
	private let modelContext: ModelContext

	init(modelContext: ModelContext) {
		self.modelContext = modelContext
	}

	private var items: [Item] {
		(try? modelContext.fetch(FetchDescriptor())) ?? []
	}

	func addItem() {
		let newItem = Item(timestamp: Date())
		modelContext.insert(newItem)
	}

	func deleteItems(offsets: IndexSet) {
		for index in offsets {
			modelContext.delete(items[index])
		}
	}
}

The cumbersome instantiation pattern of view models that require the model context to be injected through their initializer

Since the ViewModel class requires the model context to be injected through its initializer, it cannot be instantiated as a default property value in the property’s declaration.

struct ContentView: View {
	@Environment(\.modelContext) private var modelContext
	@Query private var items: [Item]
	// error: Cannot use instance member 'modelContext' within property initializer; property initializers run before 'self' is available
	private var viewModel: ViewModel = ViewModel(modelContext: modelContext)

	// ...
}
Best practice

It is not strictly necessary to require the model context to be injected through the initializer.

It could also be injected later through a method or an optional stored property. In fact, that is required if we want to avoid having an optional view model in the view.

However, it is a good practice for a class to require its dependencies at initialization, as it removes unnecessary optionals and prevents programming mistakes.

The view model cannot be instantiated in the view’s initializer either, for two reasons:

  1. The SwiftUI environment is not yet available in a view’s initializer, but only when the view’s body runs.
  2. Instantiating a @State property in a view’s initializer resets it every time SwiftUI updates the view hierarchy.
struct ContentView: View {
	@Environment(\.modelContext) private var modelContext
	@Query private var items: [Item]
	@State private var viewModel: ViewModel

	init(modelContext: ModelContext) {
		// Recreates the view model every time the view hierarchy is refreshed.
		self._viewModel = State(initialValue: ViewModel(modelContext: modelContext))
	}

	// ...
}

Moreover, passing the model context as a parameter to the initializer

The same applies to creating the view model in the parent view and passing it as an initializer parameter.

Note

The above pattern would work with the old @StateObject property wrapper since, unlike @State, it has an initializer with an autoclosure that runs only once, even if the view’s initializer runs multiple times.

In any case, that works only with objects that do not require an environment value to be injected.

Observable objects in @State properties that require environment values can be properly instantiated in the task(_:) view modifier, as detailed by Apple’s documentation. However, there is a better way, as we will see later in this article.

ContentView.swift

struct ContentView: View {
	@Environment(\.modelContext) private var modelContext
	@Query private var items: [Item]
	@State private var viewModel: ViewModel?

	var body: some View {
		NavigationSplitView {
			// ...
		} detail: {
			// ...
		}
		.task {
			guard viewModel == nil else { return }
			viewModel = ViewModel(modelContext: modelContext)
		}
	}

	private func addItem() {
		withAnimation {
			viewModel?.addItem()
		}
	}

	private func deleteItems(offsets: IndexSet) {
		withAnimation {
			viewModel?.deleteItems(offsets: offsets)
		}
	}
}

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices

MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.

DOWNLOAD THE FREE GUIDE

The shared model context is available to any dynamic property, not just @Query properties

The detractors of MVVM in SwiftUI decry the cumbersome initialization pattern I showed above, as well as the annoying optional viewModel property that must be unwrapped at every use.

Update

Some have asked about what I would use in production code. I would stop at the code I’ve shown so far, as I don’t mind initializing the view model in the task(_:) modifier.

I also consider the related optional stored property to be only a minor and acceptable annoyance. However, I know there are developers hell-bent on avoiding it at all costs.

I do have a solution that removes the need for an optional based on the state pattern, but that’s out of scope for this article.

However, I use that only for complex view models, as it is quite verbose and requires a lot more boilerplate code, making it a poor choice if your objective is to just avoid optionals.

Below, I’ll show an alternative.

@Query properties seem to have a mysterious ability to access the model context shared through the SwiftUI environment, which is one of the reasons why many believe SwiftData and SwiftUI are inextricably connected.

However, a bit of curiosity can dissolve that mystery and provide a more convenient way to initialize our view models.

Examining the expansion of the @Query macro, you will notice that the generated stored property has a Query type, a structure that conforms to the  DynamicProperty protocol, providing the second piece of the puzzle.

Misconception

Many developers erroneously believe that a @Query property accesses the shared SwiftData model context using some private API available only to Apple.

However, any property wrapper conforming to the DynamicProperty protocol, including custom ones, can access the SwiftUI environment.

This means we can implement a custom property wrapper that instantiates a view model and accesses the shared model context only when it is available, i.e., when the body of the view is executed.

However, in this case, we can’t inject the model context into the view model at initialization because it isn’t available yet, as discussed above.

SwiftDataViewModel.swift

protocol ContextReferencing {
	init()
	func update(with modelContext: ModelContext)
}

@propertyWrapper struct SwiftDataViewModel: <VM: ContextReferencing> DynamicProperty {
	@State var viewModel = VM()
	@Environment(\.modelContext) private var modelContext

	var wrappedValue: VM {
		return viewModel
	}

	func update() {
		viewModel.update(with: modelContext)
	}
}
Update

An earlier version of this code initialized the view model in the update() method if one wasn’t present yet. However, that was a mistake, as it created a new view model instance every time the method was executed.

The ViewModel class needs to conform to the ContextReferencing protocol and receive the model context through method injection.

ViewModel.swift

@Observable final class ViewModel: ContextReferencing {
	private var modelContext: ModelContext?

	func update(with modelContext: ModelContext) {
		self.modelContext = modelContext
	}

	func addItem() {
		let newItem = Item(timestamp: Date())
		modelContext?.insert(newItem)
	}

	func deleteItems(offsets: IndexSet) {
		for index in offsets {
			modelContext?.delete(items[index])
		}
	}
}

While this introduces an optional into the view model, it allows updating the model context reference when the one in the SwiftUI environment changes.

We can now use our custom property wrapper in the view and seamlessly initialize the view model, removing the need for the task(_:) view modifier and optional unwrapping.

ContentView.swift

struct ContentView: View {
	@Environment(\.modelContext) private var modelContext
	@Query private var items: [Item]
	@SwiftDataViewModel private var viewModel: ViewModel 

	var body: some View {
		NavigationSplitView {
			// ...
		} detail: {
			// ...
		}
		   
	}

	private func addItem() {
		withAnimation {
			viewModel .addItem()
		}
	}

	private func deleteItems(offsets: IndexSet) {
		withAnimation {
			viewModel .deleteItems(offsets: offsets)
		}
	}
}

View models don’t need to update their stored properties after every change in the model context

With the addition of the SwiftDataViewModel property wrapper, the view model can now incorporate stored properties.

However, keep in mind that this is not particularly important for MVVM with SwiftData, as a view can use a @Query property in conjunction with a view model.

The answer to the riddle is again in the DynamicProperty protocol, to which every SwiftUI property wrapper conforms. In the documentation for its update() requirement, we read:

SwiftUI calls this function before rendering a view’s body to ensure the view has the most recent value.

Update

An earlier version of this section stated that @Query properties are not notified about changes to the model context and do not drive a view refresh.

However, that statement was at least partially incorrect, if not completely, as a reader pointed out in this LinkedIn comment.

@Query properties do trigger view updates when the model context changes, even though we can’t know how it works exactly. I attempted to investigate the exact mechanism, but I was unable to determine how it occurs.

My suspicion is that the Query structure relies on Core Data notifications for updates, since SwiftData is built on top of it, but it could also utilize private APIs.

In the end, the answer remains hidden in Apple’s implementation, at least for the time being. Fortunately, this technical detail does not invalidate the point of this article, which is architectural and not tied to such implementation details.

Thanks to the update() requirement of DynamicProperty, our view model can keep any stored property up to date in a single place, rather than across all its methods, as you commonly see in online examples.

ViewModel.swift

@Observable final class ViewModel: ContextReferencing {
	var items: [Item] = []
	private var modelContext: ModelContext?

	func update(with modelContext: ModelContext) {
		self.modelContext = modelContext
		items = (try? modelContext.fetch(FetchDescriptor())) ?? []
	}

	// ...
}

Keep in mind that this is not necessary in our example, as I have explained above, and I would not recommend implementing it without a reason. Using a @Query property in the view is simpler. However, this approach can be useful when a view model is more complex than the one in our example.

The ContentView still needs its @Query property since that is what causes a view refresh, which in turn runs the update() method of our property wrapper.

However, the view can reference the stored property of the view model, which is useful when they are not a mere replica of a @Query property in the view.

ContentView.swift

struct ContentView: View {
	@Environment(\.modelContext) private var modelContext
	@Query private var items: [Item]
	@SwiftDataViewModel private var viewModel: ViewModel

	var body: some View {
		NavigationSplitView {
			List {
				ForEach(viewModel.items) { item in
					// ...
				}
				.onDelete(perform: deleteItems)
			}
			// ...
		} detail: {
			// ...
		}
	}

	// ...
}

Conclusions

Most online claims about MVVM and SwiftData can be debunked, demonstrating that MVVM is perfectly compatible with SwiftData.

With a proper understanding of the single source of truth principle that drives SwiftUI’s architecture, it is possible to combine view models, @Query properties, and the shared model context.

Moreover, the DynamicProperty protocol allows us to remove all boilerplate code and simplify view model initialization.

Finally, the DynamicProperty protocol helps us keep view models up to date with changes in the shared model context, eliminating the need to explicitly update a view model’s stored properties.

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices

MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.

DOWNLOAD THE FREE GUIDE