Is SwiftData Incompatible with MVVM?

2 weeks ago 2

Some developers claim that MVVM is incompatible with SwiftUI.

However, with a proper understanding of SwiftUI, it is possible to address any criticism and remove the boilerplate code shown 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.

Table of contents

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. 

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 of the complaints about MVVM with SwiftData stem from the false 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 the moment, stating 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.

Moreover, as we will see in this article, they are not unique to SwiftData, but they are common SwiftUI features you can use in your code.

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.

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

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.

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 based on their own bad 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 faulty because it does not react to any changes in the model context caused by code outside of the view model.

However, this implementation betrays a poor understanding of how SwiftUI works. While it is fine not to know everything, and I definitely don’t, it is unacceptable to spread inaccurate claims that have not been properly investigated.

Misconception #1: @Query properties are not sources of truth

The first 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 an optional stored property.

However, it is a good practice to require the dependencies an object needs to function in its initializer, 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 a parameter to the initializer.

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) } } }

Misconception #2: Accessing the shared model context is not a unique ability of @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.

They contrast this with the mysterious ability of @Query properties to access the model context shared through the SwiftUI environment, which is one of the reasons why they 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 that we can implement a custom property wrapper that instantiates a view model, accessing the shared model context only when it is available, i.e., when the body of the view is executed.

SwiftDataViewModel.swift

protocol ContextReferencing { init(modelContext: ModelContext) } @propertyWrapper struct SwiftDataViewModel: DynamicProperty { @State var viewModel: VM! @Environment(\.modelContext) private var modelContext var wrappedValue: VM { return viewModel } mutating func update() { guard viewModel == nil else { return } _viewModel = State(initialValue: VM(modelContext: modelContext)) } }

The ViewModel class only needs to conform to the ContextReferencing protocol.

ViewModel.swift

@Observable class ViewModel: ContextReferencing { private let modelContext: ModelContext required init(modelContext: ModelContext) { self.modelContext = modelContext } // ... }

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) } } }

Misconception #3: @Query properties are not notified of changes in the model context, but actively update their content

There is one last myth we need to debunk. Many developers think that @Query properties are notified about changes to the model context, which then drive a view refresh.

However, searching the SwiftData documentation for public APIs does not yield any results (although there are notifications when a context is saved). This leads those developers to mistakenly believe that this, again, can only be achieved by Apple using private APIs.

This belief is false and, again, betrays a profound misunderstanding of how SwiftUI user interface refreshing works.

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.

Misconception

@Query properties are not actively notified about changes to the model context. Instead, they fetch their content just before a view update through the update() requirement of the DynamicProperty protocol.

While this fact is not reported by the documentation of the Query structure, it can be deduced by looking into the header file of the SwiftData framework, which can be accessed in Xcode by ⌘-clicking on a SwiftData symbol.

This means that it is possible, if needed, to remove any @Query property from a view altogether, adding a corresponding stored property to the view model.

First, we need to add the update requirement to our ContextReferencing protocol so that we can call the method from our custom property wrapper.

SwiftDataViewModel.swift

protocol ContextReferencing { init(modelContext: ModelContext) func update() } @propertyWrapper struct SwiftDataViewModel: DynamicProperty { // ... mutating func update() { if viewModel == nil { _viewModel = State(initialValue: VM(modelContext: modelContext)) } viewModel.update() } }

Then, we can add a stored property to the ViewModel class and implement the requirement.

ViewModel.swift

@Observable class ViewModel: ContextReferencing { var items: [Item] = [] private let modelContext: ModelContext // ... func update() { items = (try? modelContext.fetch(FetchDescriptor())) ?? [] } // ... }

Keep in mind that this is not necessary in our view model, as I have explained above, and I would not recommend implementing it without a reason. Keeping a @Query property in the view is simpler.

However, it can be useful when a view model is more complex than the one in our example.

The ContentView does not need its @Query property anymore and can, instead, reference the stored property of the view model.

ContentView.swift

struct ContentView: View { @Environment(\.modelContext) private var modelContext @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, by properly understanding the DynamicProperty protocol, it’s possible to create a simple property wrapper that removes all boilerplate code and simplifies initialization.

Finally, a better understanding of how SwiftUI view updates work ensures that view models remain up to date with changes in the shared model context, eliminating the need for explicit updates to the view model’s stored properties.

Read Entire Article