Splitting SwiftData and SwiftUI via MVVM
Tags: swiftui, ios, swift, swiftdata
Published: 2023-10-31 22:20:54

---
title: Splitting SwiftData and SwiftUI via MVVM
published: true
description: How to remove coupling between SwiftData persistence and SwiftUI view layers
tags: SwiftUI, iOS, Swift, SwiftData
# cover_image: https://direct_url_to_image.jpg
# Use a ratio of 100:42 for best results.
# published_at: 2023-10-25 07:18 +0000
---

[SwiftData](https://developer.apple.com/xcode/swiftdata/) was announced at WWDC in June 2023. Its primary goal is to enable data persistence in a SwiftUI app with as little fuss as possible.

Most of the tutorials and snippets we see on the web show it being used directly in a SwiftUI View. For example, let's consider the minimal code to query, insert, and delete items from a list.

Suppose we have a model of an item that we'll want to display in a list. For example, here's the `@Model` that XCode generates if we select "use SwiftData" in the project template:

```swift
@Model
final class Item {
    var timestamp: Date
    
    init(timestamp: Date) {
        self.timestamp = timestamp
    }
}
```

With _very_ little code, we can use the `@Query` property wrapper to read some existing items and show them in a list:
```swift
struct ContentView: View {
    @Query private var items: [Item]
    
    var body: some View {
        List {
            ForEach(items) { item in
                Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
            }
        }
    }
}
```

Of course, we'll also need some way to add and remove those items. For whatever reason, those actions happen through a separate `ModelContext` object:

```swift
struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]
    
    var body: some View {
        VStack {
            Button(action: {
                modelContext.insert(Item(timestamp: Date()))
            }) {
                Label("Add Item", systemImage: "plus")
            }
            List {
                ForEach(items) { item in
                    Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
                }
                .onDelete(perform: { offsets in
                    for index in offsets {
                        modelContext.delete(items[index])
                    }
                })
            }
        }
    }
}
```

Above, we've included a simple button to add new `Item`s, and a swipe-to-delete interaction that can delete `Item`s. Here's what the UI ends up looking like:

![A simple SwiftUI list showing add and delete actions](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/we91fuj7jxdyhcwammys.png)

The magic here, of course, is that the items will persist even after the app is killed: that's the point of using SwiftData.


# First Attempt at MVVM & SwiftData

Let's now suppose that we want to split apart our code and introduce an MVVM pattern. Doing so will help us to create testable, reusable layers in our app.

We have a few new challenges here, using SwiftData. Firstly, we'll need a way to get access to the `ModelContext` outside of the SwiftUI View. Let's try a naive approach of using the same `@Environment` property binding to access `ModelContext` from our new _ViewModel_, instead of the `ContentView`. We might end up with something like below. Note that all data-management actions have been moved out of the `ContentView` and now live in a new class `ViewModel`.

```swift
struct ContentView: View {
    @State private var viewModel = ViewModel()
    
    var body: some View {
        VStack {
            Button(action: {
                viewModel.appendItem()
            }) {
                Label("Add Item", systemImage: "plus")
            }
            List {
                ForEach(viewModel.items) { item in
                    Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
                }
                .onDelete(perform: { offsets in
                    for index in offsets {
                        viewModel.removeItem(index)
                    }
                })
            }
        }
    }
}
```

And a really simple-minded ViewModel to go with it:
```swift
@Observable
class ViewModel {
    @ObservationIgnored
    @Environment(\.modelContext) private var modelContext
    
    var items: [Item] = []
    
    func appendItem() {
        modelContext.insert(Item(timestamp: Date()))
    }
    
    func fetchItems() {
        do {
            items = try modelContext.fetch(FetchDescriptor<Item>())
        } catch {
            fatalError(error.localizedDescription)
        }
    }
    
    func removeItem(_ index: Int) {
        modelContext.delete(items[index])
    }
}
```

At this point, we try to run our simple app and we get this error:

> `Accessing Environment<ModelContext>'s value outside of being installed on a View. This will always read the default value and will not update.`
 
Darn.

# Updating App integration

Let's go back and look at our `App` class for a moment. The simplest integration for SwiftData is to inject the `ModelContext` into the `WindowGroup`, like this, which makes it available to SwiftUI:

```swift
@main
struct DataPlaygroundApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Item.self)
    }
}
```

It's worth noting that [`mainContext`](https://developer.apple.com/documentation/swiftdata/modelcontainer/maincontext) is a property inside of a `ModelContainer`. So, instead of accessing the `ModelContext` via `@Environment`, maybe our ViewModel could access it through the `ModelContainer`.

Let's remove that `modelContainer(...)` line in the `App` altogether and try a different approach. Instead of accessing it from the View layer _at all_, let's create a data source class.

Our App now looks like this:

```swift
@main
struct DataPlaygroundApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
```

# Defining a Data Source

Now, let's define an `ItemDataSource` that will own the `ModelContainer`:

```swift
final class ItemDataSource {
    private let modelContainer: ModelContainer
    private let modelContext: ModelContext

    @MainActor
    static let shared = ItemDataSource()
    
    @MainActor
    private init() {
        self.modelContainer = try! ModelContainer(for: Item.self)
        self.modelContext = modelContainer.mainContext
    }
    
    func appendItem(item: Item) {
        modelContext.insert(item)
        do {
            try modelContext.save()
        } catch {
            fatalError(error.localizedDescription)
        }
    }
    
    func fetchItems() -> [Item] {
        do {
            return try modelContext.fetch(FetchDescriptor<Item>())
        } catch {
            fatalError(error.localizedDescription)
        }
    }
    
    func removeItem(_ item: Item) {
        modelContext.delete(item)
    }
}
```

A few things to notice here. The class uses `@MainActor`, since we must access `mainContext` from the main actor. If you _don't_ include `@MainActor`, you'll get a compile error like this:

> `Main actor-isolated property 'mainContext' can not be referenced from a non-isolated context`

Also, I've made the component a singleton so we only have one instance of the `ModelContainer`. (There are many other ways to achieve this, this was easy for demonstration purposes.)

Now, our simple `ViewModel` just calls out to the data source. When `items` changes, since the `ViewModel` is `@Observable`, the `ContentView` will automatically respond to it and render the new items.

```swift
@Observable
class ViewModel {
    @ObservationIgnored
    private let dataSource: ItemDataSource
    
    var items: [Item] = []
    
    init(dataSource: ItemDataSource = ItemDataSource.shared) {
        self.dataSource = dataSource
        items = dataSource.fetchItems()
    }
    
    func appendItem() {
        dataSource.appendItem(item: Item(timestamp: Date()))
    }

    func removeItem(_ index: Int) {
        dataSource.removeItem(items[index])
    }
}
```

And there you have it! You can re-use the `ItemDataSource` in any number of your SwiftUI View/ViewModels, and you can start building up tests around each component in isolation.

Let me know in the comments what you think of this approach.