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.

Create a free account to save this page, take notes and track your progress. Sign in

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 @Model and its stored properties become persisted fields.
  • .modelContainer(for: Note.self) on the app sets up the database.
  • In views, @Query fetches and observes data: the view updates when it changes.
  • @Environment(\.modelContext) gives you the context to insert and delete; changes save automatically.
  • #Predicate and SortDescriptor filter 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):

A local cache and merge rules
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 #Predicate in @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 @Query drive the UI.
  • Passing model objects across actors. @Model objects aren't Sendable; pass their persistentModelID and fetch in the other context.

Key points

  • @Model, .modelContainer, @Query and modelContext cover 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.

Was this page helpful?