iOS with SwiftUI · Navigation, Networking, Persistence and Concurrency
Persistence with SwiftData
Store data on the device with SwiftData: @Model classes, a model container, @Query to read and the model context to insert and delete, plus how to cache server data for offline use.
What you'll build
A notes app that keeps notes on the device, shows them sorted by last edit, lets you add, edit and delete them without a connection, and caches data from the server so the app opens instantly.
The idea
SwiftData (iOS 17 and later) is Apple's Swift-native persistence framework, built on the same storage engine as Core Data:
- Mark a class with
@Modeland its stored properties become persisted fields. .modelContainer(for: Note.self)on the app sets up the database.- In views,
@Queryfetches and observes data: the view updates when it changes. @Environment(\.modelContext)gives you the context toinsertanddelete; changes save automatically.#PredicateandSortDescriptorfilter and sort in the store, not in memory.
For server data, treat the local store as the source of truth for the UI: download, upsert into SwiftData, and let @Query update the screen. Local edits are marked as pending and uploaded later.
The code
import SwiftData
@Model
final class Note {
@Attribute(.unique) var id: UUID
var text: String
var updatedAt: Date
var pendingSync: Bool
init(text: String, updatedAt: Date = .now) {
self.id = UUID()
self.text = text
self.updatedAt = updatedAt
self.pendingSync = true
}
}
@main
struct NotesApp: App {
var body: some Scene {
WindowGroup { NotesView() }
.modelContainer(for: Note.self)
}
}
struct NotesView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Note.updatedAt, order: .reverse) private var notes: [Note]
var body: some View {
NavigationStack {
List {
ForEach(notes) { note in
Text(note.text)
}
.onDelete { offsets in
for index in offsets { context.delete(notes[index]) }
}
}
.toolbar {
Button("Add", systemImage: "plus") { context.insert(Note(text: "New note")) }
}
.navigationTitle("Notes")
}
}
}
Previews use an in-memory container: .modelContainer(for: Note.self, inMemory: true). When a model changes shape in a later version, define a VersionedSchema and a SchemaMigrationPlan so existing users keep their data.
Try the logic
SwiftData itself needs an Apple platform, but the sync rules don't. Here local notes are cached as JSON in a file and merged with server changes (last write wins, unsynced local edits kept):
import Foundation
struct Note: Codable {
let id: String
var text: String
var updatedAt: Int
var pendingSync = false
}
func merge(local: [Note], remote: [Note]) -> [Note] {
var byID = Dictionary(uniqueKeysWithValues: local.map { ($0.id, $0) })
for r in remote {
if let l = byID[r.id], l.pendingSync, l.updatedAt > r.updatedAt { continue } // keep our newer edit
byID[r.id] = r
}
return byID.values.sorted { $0.updatedAt > $1.updatedAt }
}
let cacheURL = FileManager.default.temporaryDirectory.appendingPathComponent("notes-\(UUID()).json")
defer { try? FileManager.default.removeItem(at: cacheURL) }
let local = [Note(id: "a", text: "Buy milk (edited offline)", updatedAt: 300, pendingSync: true),
Note(id: "b", text: "Call Ana", updatedAt: 100)]
try JSONEncoder().encode(local).write(to: cacheURL) // saved on the device
let cached = try JSONDecoder().decode([Note].self, from: Data(contentsOf: cacheURL))
print("opened instantly with \(cached.count) cached notes")
let remote = [Note(id: "a", text: "Buy milk", updatedAt: 200),
Note(id: "b", text: "Call Ana at 5pm", updatedAt: 250),
Note(id: "c", text: "Book flights", updatedAt: 150)]
for n in merge(local: cached, remote: remote) {
print("\(n.id) | \(n.text) | \(n.updatedAt) | pending=\(n.pendingSync)")
}
Output
opened instantly with 2 cached notes a | Buy milk (edited offline) | 300 | pending=true b | Call Ana at 5pm | 250 | pending=false c | Book flights | 150 | pending=false
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Filtering in memory.
notes.filter { ... }on a large query loads everything; use a#Predicatein@Query. - Shipping model changes without a migration plan. Renaming or retyping a property can fail to open existing stores; use versioned schemas.
- Showing network results directly. The app is empty offline; write to the store and let
@Querydrive the UI. - Passing model objects across actors.
@Modelobjects aren'tSendable; pass theirpersistentModelIDand fetch in the other context.
Key points
@Model,.modelContainer,@QueryandmodelContextcover most persistence needs.- Queries observe the store, so the UI updates when data changes.
- Make the local store the source of truth and sync with the server in the background.
- Plan schema migrations with versioned schemas.
Further reading: SwiftData, Preserving your app's model data across launches.
Practice questions
How does a SwiftUI view stay up to date with SwiftData?
Answer
The @Query property wrapper fetches models from the model context and observes the store; when models are inserted, changed or deleted, the query results update and SwiftUI re-renders the view, with no manual reload.
Why shouldn't you pass a SwiftData model object to another actor or task?
Answer
Model objects belong to the model context they were fetched from and aren't Sendable. Pass the model's persistentModelID across the boundary and fetch it again from a context owned by that actor (for example a @ModelActor).
Open in the app to mark lessons complete, take notes and track your progress.