Code-along: Add persistence with SwiftData
Add SwiftData persistence to an existing SwiftUI app by converting state to @Model schemas, relationships, model containers, and targeted @Query fetches.
TL;DR
- Migrates the Wishlist SwiftUI sample from an in-memory DataSource to SwiftData-backed persistence using @Model, ModelContainer, ModelContext, and @Query.
- Shows schema design decisions: make persisted properties mutable and Codable, convert an enum-based Goal into model classes, and model Trip-to-Activity as a relationship.
- Demonstrates targeted SwiftUI queries with #Predicate, FetchDescriptor, SortDescriptor, fetch limits, and initializer-built Query values for filtering, search, and recency lists.
- Covers practical cleanup: remove redundant state-management helpers, rely on autosave, surface runtime errors, and restore side effects such as dateEdited updates with continuous observation.
Migration goal: replace in-memory app state with SwiftData
The session starts from the Wishlist sample app, a SwiftUI travel-planning app that stores trips, activities, goals, and search state in an environment-provided DataSource. That design works for a small demo, but filtering and sorting happen in memory, edits are lost on relaunch, and memory use grows with hardcoded data.
The migration path is framed as three steps: identify relevant dynamic state, define SwiftData model schemas for that state, and declare relationships so views can query persistent data efficiently through a model context.
- Relevant app state includes trip collections, goal statuses, trips, activities, photos, and search results.
- The DataSource responsibilities are replaced by SwiftData models, ModelContext operations, relationships, and SwiftUI queries.
- Persistent storage changes the design tradeoff: fetch only the data each view needs instead of loading and filtering broad in-memory collections.
Define SwiftData schemas with @Model
The Activity and Trip types are converted from observable app types into SwiftData models by importing SwiftData and applying @Model. SwiftData supplies observable conformance for model types, so the previous Observable macro is no longer needed.
Model properties that are loaded from persistent storage must be writable, so values such as creationDate need to be declared with var rather than let. Non-model value properties that should be stored in database columns, such as TripCollection, must conform to Codable.
The original Goal enum is refactored because an enum represents a closed set of cases and cannot store per-instance persistent progress. The model becomes a class with stored progress state, and TripGoal and ActivityGoal subclasses represent the two goal categories using SwiftData model inheritance.
- Use @Model for persistent classes such as Activity, Trip, Goal, TripGoal, ActivityGoal, and TripImage.
- Remove didSet observers from model properties while bringing the project back to a buildable schema, then reintroduce needed side effects later through observation.
- Store goal progress directly on the model, including completedCount and an isComplete-style persisted value that can be queried.
Convert an Activity to a SwiftData model
@Model makes Activity persistent and observable through SwiftData; simple stored properties become part of the schema.
import Foundation
import SwiftData
@Model
class Activity {
var name: String
var isComplete: Bool = false
var dateCreated = Date.now
var dateEdited = Date.now
}Make persisted value types Codable
TripCollection can be stored as a model property because SwiftData can encode it into a database column.
enum TripCollection: String, CaseIterable, RawRepresentable, Codable {
case springEscapes
case summerVibes
case fallGetaways
case winterRetreats
}Model relationships and app-wide container setup
Wishlist previously approximated relationships using dictionaries and ID-based lookups. The SwiftData version declares relationships directly on model types, so a Trip can own many Activity models and activity-driven views can navigate back to parent trip details.
The Trip model uses @Relationship with a cascade delete rule and an inverse to Activity.trip. Deleting a trip clears its associated activities. The session also changes image storage: thumbnailData is inlined for efficient list and carousel UI, while full-resolution image handling is moved to a separate TripImage model.
After schemas are defined, the app creates a ModelContainer for all model types, seeds sample data if needed, and attaches the container to the SwiftUI scene with .modelContainer(container). At that point, Query can access the schema from the view hierarchy.
- TripEditModel becomes unnecessary because SwiftUI can bind directly to SwiftData models.
- DataSource becomes unnecessary because ModelContext, queries, and relationships handle storage, filtering, sorting, traversal, and updates.
- The model container is the integration point between the SwiftData schema and the SwiftUI scene.
Declare Trip-to-Activity relationship
An array property models the to-many relationship, and the cascade rule removes child activities when a trip is deleted.
import Foundation
import SwiftData
@Model
class Trip {
var name: String
var collection: TripCollection
var photo: TripImage
var thumbnailData: Data?
@Relationship(deleteRule: .cascade, inverse: \Activity.trip)
var activities: [Activity] = []
private(set) var creationDate = Date.now
var subtitle: String?
var isComplete: Bool = false
}Create and install the ModelContainer
The app registers the full schema once and makes it available to SwiftUI queries through the scene modifier.
import SwiftUI
import SwiftData
@main
struct WishlistApp: App {
let container: ModelContainer = {
do {
let modelContainer = try ModelContainer(
for: Trip.self, Activity.self, TripImage.self,
Goal.self, TripGoal.self, ActivityGoal.self
)
try SampleData.seedIfNeeded(in: modelContainer.mainContext)
return modelContainer
} catch {
fatalError("Could not create model container: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
.preferredColorScheme(.dark)
}
.modelContainer(container)
}
}Replace DataSource reads with targeted @Query fetches
The view layer is updated to ask SwiftData for exactly the data each subview needs. Query is described as equivalent to fetching from a model context, with the SwiftUI advantage that the view automatically updates when query results change.
The session emphasizes performance: persistent storage may live outside the app address space, so fetching broad sets and filtering in memory can waste I/O and memory. Predicates, sort descriptors, and fetch limits should be used to shape each query before data is loaded into the view.
- GoalsView uses separate queries for achieved and upcoming goals.
- RecentTripsPageView fetches the five most recent trips in reverse chronological order.
- TripCollectionView constructs its Query in init so the predicate can capture the selected TripCollection.
- SearchResultsListView switches between recent-trip fallback results and name-based Trip and Activity predicates.
Fetch achieved and upcoming goals
Two focused queries avoid fetching all goals and separating them in memory.
@Query(
filter: #Predicate<Goal> { $0.isAchieved },
sort: \Goal.dateAchieved,
order: .reverse
)
private var achievedGoals: [Goal]
@Query(
filter: #Predicate<Goal> { !$0.isAchieved },
sort: \Goal.sortOrder
)
private var upcomingGoals: [Goal]Build a query from initializer input
The selected collection is captured in the predicate before the query is sent to storage.
init(tripCollection: TripCollection, cardSize: TripCard.Size, namespace: Namespace.ID) {
_trips = Query(
filter: #Predicate<Trip> { $0.collection == tripCollection },
sort: \Trip.name
)
self.tripCollection = tripCollection
self.cardSize = cardSize
self.namespace = namespace
}Search trips and activities
Search uses different query plans for empty and non-empty search text, including a predicate that only returns activities attached to a trip.
if searchText.isEmpty {
_trips = Query(
FetchDescriptor(
sortBy: [SortDescriptor(\Trip.creationDate, order: .reverse)],
fetchLimit: 3
)
)
_activities = Query(filter: #Predicate<Activity> { _ in false })
} else {
let tripSearchPredicate = #Predicate<Trip> {
$0.name.localizedStandardContains(searchText)
}
_trips = Query(filter: tripSearchPredicate, sort: \Trip.name)
let activitySearchPredicate = #Predicate<Activity> {
$0.trip != nil && $0.name.localizedStandardContains(searchText)
}
_activities = Query(filter: activitySearchPredicate, sort: \Activity.name)
}Autosave, errors, and model side effects
With the model container installed, automatic saving is enabled by default. The demo verifies the migration by adding a new trip, relaunching, and seeing the trip remain in the app.
Persistence introduces runtime failure modes such as storage capacity problems or unsupported predicates. The session updates ActivityItemView to capture thrown errors from updateGoalAchievements, report them to telemetry, and present an alert when recovery guidance is appropriate.
The earlier didSet behavior for dateEdited is restored using continuous observation. ActivityItemView observes edits to an Activity's name and completion state, updates dateEdited, and also updates the parent Trip's isComplete state when activity completion changes.
- Use SwiftUI error-presentation modifiers to surface recoverable persistence-related failures.
- Keep side effects near the UI interaction that performs the edit when property observers are not suitable for @Model properties.
- Updating dateEdited can trigger query-driven views sorted by edit date to refresh automatically.
Capture and present update errors
A thrown update error is stored, reported, and bound to a SwiftUI alert.
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 17) {
Group {
if isEditing {
rowContentWhenEditing
} else {
rowContentWhenNotEditing
}
}
.transition(.opacity.animation(.snappy))
.animation(.snappy, value: isEditing)
}
.onDisappear {
do {
try updateGoalAchievements()
} catch {
updateError = error
reportError(error)
}
}
.alert(error: $updateError) {
// Customize the presentation of the error
}
}Restore dateEdited and trip completion side effects
Continuous observation reintroduces side effects that were formerly handled by property observers.
init(activity: Activity, isLast: Bool, isEditing: Bool) {
activity.token = withContinuousObservation(options: .didSet) { event in
_ = activity.name
_ = activity.isComplete
if event.matches(\Activity.name) {
activity.dateEdited = .now
}
if event.matches(\Activity.isComplete) {
activity.dateEdited = .now
activity.trip?.isComplete =
activity.trip?.activities.isEmpty == false &&
activity.trip?.activities.allSatisfy { $0.isComplete } == true
}
}
self.activity = activity
self.isLast = isLast
self.isEditing = isEditing
}