SwiftData, my first look

This article is part of a series.

SwiftData seems to be a very good fit for simple projects. When using defaults developers get a lot of features right out of the gate, like change history and undo. Default settings also syncs across devices easily with CloudKit, with a few caveats.

In 2024 Apple added a DataStore protocol so you can swap in your own preferred persistence style, rather than the very fancy wrapper around SQLite that it comes with out of the box.

Resources

The pieces

After going through some basic hello world tutorials/intro WWDC videos, definitely do watch the 2023 Dive Deeper video to understand how the parts work together.

Schema

If this word is unfamiliar to you: https://en.wikipedia.org/wiki/Database_schema

@Model‘ing classes in your code is how SwiftData adds what it needs to use those classes to build a schema. There are a couple of ways to set up a Schema type. The fast way when there is only one class is just to give that @Model decorated class to the simple ModelContainer view modifier style init.

@Model
final class MyModelType {
    var item:Item?
    
    init(item:Item) {
        self.item = item
    }
}

SomeView().modelContainer(for: MyModelType.self, inMemory: true)

Expanding that macro adds the following code:

import Foundation
import SwiftData



final class MyModelType {
    @_PersistedProperty var item:Item?
    
    init(textInfo: String, timestamp: Date) {
        self.item = Item(textInfo: textInfo, timestamp: timestamp)
    }
    
    @Transient
    private var _$backingData: any SwiftData.BackingData<MyModelType> = MyModelType.createBackingData()
    
    public var persistentBackingData: any SwiftData.BackingData<MyModelType> {
        get {
            return _$backingData
        }
        set {
            _$backingData = newValue
        }
    }
    
    static var schemaMetadata: [SwiftData.Schema.PropertyMetadata] {
        return [
            SwiftData.Schema.PropertyMetadata(name: "item", keypath: \MyModelType.item, defaultValue: nil, metadata: nil)
        ]
    }
    
    init(backingData: any SwiftData.BackingData<MyModelType>) {
        _item = _SwiftDataNoType()
        self.persistentBackingData = backingData
    }
    
    @Transient private let _$observationRegistrar = Observation.ObservationRegistrar()
    
    internal nonisolated func access<_M>(
        keyPath: KeyPath<MyModelType, _M>
    ) {
        _$observationRegistrar.access(self, keyPath: keyPath)
    }
    
    internal nonisolated func withMutation<_M, _MR>(
        keyPath: KeyPath<MyModelType, _M>,
        _ mutation: () throws -> _MR
    ) rethrows -> _MR {
        try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
    }
    
    struct _SwiftDataNoType {
    }
}

extension MyModelType: SwiftData.PersistentModel {
}

extension MyModelType: Observation.Observable {
}

Note the conformance to Observation.Observable at the end there, which is part of how SwiftData works so seamlessly with SwiftUI.

If there will be more than one class as part of the schema one can declare a Schema type explicitly by giving it an array of all the relevant macro’ed types.

let schema = Schema([
            MyModelType.self, MyHelperType.self
        ])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)

Glue Layer Between Models and Persistence

So whole point of using SwiftData is to persist the data associated with the models across app launches. There needs to be code that moves the information between active use and persistent storage and vice versa. In SwiftData the ModelContainer hold the description of how that data will be saved and retrieved.

ModelContainer has a configuration type ModelConfiguration with several initializers like the two below:

ModelConfiguration(_ name: String?, 
                    schema: Schema?, 
                    isStoredInMemoryOnly: Bool, 
                    allowsSave: Bool, 
                    groupContainer: ModelConfiguration.GroupContainer, 
                    cloudKitDatabase: ModelConfiguration.CloudKitDatabase)


ModelConfiguration(<name: String?, 
                    schema: Schema?, 
                    url: URL, 
                    allowsSave: Bool, 
                    cloudKitDatabase: ModelConfiguration.CloudKitDatabase)

One can instantiate a model container using a Schema and an array of configurations:

return try ModelContainer(for: schema, configurations: [modelConfiguration])

This initializer can fail because some aspect of what you’re telling the ModelContainer to believe about the persistence layer could end up being wrong. The file location could not exist. The code could not have permission to change the file, etc.

That’s in contrast to the convenience initializer that uses the default data store as the basis of its assumed configuration. (recall: SomeView().modelContainer(for: MyModelType.self, inMemory: true))

Once the ModelContainer gets created it gets passed into an App’s ModelContext. The ModelContext does the actually pretty darn fancy footwork of moving information between the active processes and the data storage. For example, if with every little change of information the program was constantly writing to disk the code would end up being very slow. The ModelContext batches the changes, balancing speed and safety.

Make a struct and a matching model

The easiest way to check out SwiftData would be be to create a new project with SwiftData the CloudKit options selected. But following the HWS example listed in the references up top, Paul Hudson takes you through the process from the beginning. That tutorial is way more complete than these notes. Follow that. As are the WWDC videos. Watch those.

When looking at tutorials what one will see is that everyone makes their model a class when using SwiftData. And when that class is going to be used with CloudKit, each parameter has to be optional or have a default value. This helps the code know how to resolve when connections fail.

However that’s kind of annoying if one wants use structs for your data type. When writing a project without SwiftData I have typically used structs for my models and an Actor for my DataStore and protocol based PersistenceService to save the data.

I will have to change how I work to use SwiftData, but I wanted to see how keeping structs in the mix might still work.

I tried three different approaches.

Approach 1: Class wrapper for the struct

One approach uses the SwiftData model class as a thin wrapper around a struct. “Business logic” code interacts with the struct, and the UI code selects a record via the class’s code and then pulls out the underlying struct to render, update or access business logic.

Since my interest is working with CloudKit, this example shows having the struct be an Optional in the class.

import Foundation
import SwiftData


import Foundation


struct Item:Sendable, Codable, Hashable, Equatable {
    var textInfo:String
    var timestamp:Date

    var displayMe:String {
        "\(textInfo) at \(timestamp.formatted(date: .long, time: .shortened))"
    }
}

extension Item {
    init(_ text:String) {
        self.init(textInfo: text, timestamp: .now)
    }
    
    init() {
        self.init(textInfo: "Very important information!!!", timestamp: .now)
    }
}

@Model
final class ItemSDMC {
    var item:Item?
    
    init(textInfo: String, timestamp: Date) {
        self.item = Item(textInfo: textInfo, timestamp: timestamp)
    }
}

extension ItemSDMC {
    convenience init(_ text:String) {
        self.init(textInfo: text, timestamp: .now)
    }
    
    convenience init() {
        self.init(textInfo: "Very important information!!!", timestamp: .now)
    }
}
//
//  EditItemView.swift
//  DataFling
//
//

import SwiftUI

struct EditItemView: View {
    
    @Bindable var sdModelItem:ItemSDMC
    @State var nonOptionalItemHolder:Item
    
    
    init(_ item: ItemSDMC) {
        self.sdModelItem = item
        if let wrapped = item.item {
            nonOptionalItemHolder = wrapped
        } else {
            nonOptionalItemHolder = Item(textInfo: "Empty Item", timestamp: Date())
        }
    }
    
    var body: some View {
        var boundItem = Binding(
            get: {
                _nonOptionalItemHolder.wrappedValue
            },
            set: {
                //Note this should never happen in this view because
                //is ItemSDMC somehow returns a nil Item, no edit view will
                //be displayed.
                if $0.textInfo != "EmptyItem" {
                    nonOptionalItemHolder = $0
                    sdModelItem.item = $0
                } else {
                    fatalError("How did an Empty Item get sent to be updated?")
                }
            }
        )

        VStack {
            if sdModelItem.item != nil {
                Text(sdModelItem.item!.displayMe)
                Form {
                    TextField("text", text:boundItem.textInfo)
                    DatePicker("Date", selection: boundItem.timestamp)
                }
                
            } else {
                Text("There has been an mistake. This item has no data")
            }
        }
#if os(iOS)
        .navigationTitle("Edit Item")
        .navigationBarTitleDisplayMode(.inline)
#endif
    }
}

#Preview {
    @Previewable var myItem = ItemSDMC("Example Item for Preview")
    EditItemView(myItem)
}

Discussions on how to handle bindings:

Approach 2: Keep the struct dumb

This approach differs because both the class and the struct have all the fields, and also because the class would contain the business logic and the struct would be a fairly dumb data structure.

Since my interest is working with CloudKit, this example shows having each parameter having a default value, the other choice being to use optionals like in the previous approach.

One of the advantages of this approach is that the newish Observation.Observable protocol has more granularity than what you could do before, i.e. an update to one parameter won’t trigger updates unless that parameter is in fact being observed by something active in the program.

import Foundation
import SwiftData


struct ThingLite:Sendable, Codable, Hashable, Equatable {
    let textInfo:String
    let timestamp:Date
}



@Model
final class Thing {
    //using this default value requires writing some clean up logic looking for empty text info.
    var textInfo:String = ""
    //using this default value would require writing some data clean up functions looking for out of bound dates.
    var timestamp:Date = Date.distantPast
    
    init(textInfo: String, timestamp: Date) {
        self.textInfo = textInfo
        self.timestamp = timestamp
    }
    
    var displayMe:String {
        "\(textInfo) at \(timestamp.formatted(date: .long, time: .shortened))"
    }
    
    var liteCopy:ThingLite {
        ThingLite(textInfo: textInfo, timestamp: timestamp)
    }
    
    var liteCopyWithID:(data:ThingLite, id:PersistentIdentifier) {
        //(sendableCopy, id) also works
        (sendableCopy, persistentModelID)
    }
}
//
//  EditThingView.swift
//  DataFling
//
//

import SwiftUI

struct EditThingView: View {
    @Bindable var thing:Thing
    
    
    var body: some View {
        VStack {
            Text(thing.displayMe)
            Form {
                TextField("text", text:$thing.textInfo)
                DatePicker("Date", selection: $thing.timestamp)
            }
        }.task {
            await doSomeAsyncThing(thing:thing)
        }
#if os(iOS)
        .navigationTitle("Edit Item")
        .navigationBarTitleDisplayMode(.inline)
#endif
    }
    
    func doSomeAsyncThing(thing:Thing) async {
        print(thing.liteCopy)
    }
}

#Preview {
    @Previewable var myThing = Thing(textInfo:"Example for Preview", timestamp: Date())
    EditThingView(thing: myThing)
}

Third Approach: Protocol

The third approach is to use a protocol as the main basic for all the business logic. I won’t get into that too much now because its the one I’m going to be trying out for a bit.

It might look a little something like the below, although there is still things to work out about how ID would work.

import Foundation
import SwiftData


protocol Thingable:Identifiable {
    var textInfo:String { get }
    var timestamp:Date { get }
}

extension Thingable {
    var thingDisplay:String {
        "\(textInfo) with \(id) at \(timestamp.formatted(date: .long, time: .shortened))"
    }
}

extension Thingable where Self:PersistentModel {
    var thingDisplayWithID:String {
        "\(textInfo) with modelID \(self.persistentModelID.id) in \(String(describing: self.persistentModelID.storeIdentifier)) at \(timestamp.formatted(date: .long, time: .shortened))"
    }
}

struct ThingLite:Thingable, Codable, Sendable {
    var textInfo: String
    var timestamp: Date
    var id: Int
}


@Model
final class Thing:Thingable {
    //using this default value requires writng some clean up logic looking for empty text info.
    var textInfo:String = ""
    //using this default value would require writing some data clean up functions looking for out of bound dates.
    var timestamp:Date = Date.distantPast
    
    init(textInfo: String, timestamp: Date) {
        self.textInfo = textInfo
        self.timestamp = timestamp
    }
}

extension Thing {
    var LiteThing:ThingLite {
        ThingLite(textInfo: textInfo, timestamp: timestamp, id: persistentModelID.hashValue)
    }
}

If one goes that route, at least as of May 2025, it is ill advised to try to make a generic view that takes in some Thingable because Thinable’s that come from an @State deceleration will want to work with @Binding as opposed to @Model objects which, since they conform to Observation.Observable and are owned by the context, will be passed via @Bindable. Those play very poorly together since @State provides a binding to the enclosing object and @Bindable only provides bindings to the properties. .

Conclusion

Leaning into using SwiftData` requires different methods working with information than previous approaches. I imagine I’ll keep changing my mind as to what patterns I want to try out project by project for some time.

This article is part of a series.