Build a Notes Sync Feature in SwiftUI: MVVM, Async Networking, and SwiftData
This tutorial walks through a production-ready Notes Sync feature: local-first persistence with SwiftData, a clean async/await networking layer, and MVVM wiring. Target audience: intermediate iOS devs. We'll explain components, then provide full code for model, ViewModel, view, and sample networking.
Architecture & components
- Model — Note entity persisted locally with SwiftData, with a sync metadata field (serverId, updatedAt, dirty flag).
- Persistence — SwiftData container and helper queries.
- Networking — APIClient + NotesAPI providing fetch/patch endpoints. Use async/await, Codable, and a uniform error type.
- SyncManager / ViewModel — Responsible for queuing local changes, pushing to server, resolving simple conflicts (lastModified wins), and exposing UI state.
- View — SwiftUI view bound to ViewModel; real-time UI updates via @Observable and @Query (SwiftData).
Why SwiftData?
SwiftData simplifies entity modeling and integrates nicely with SwiftUI. It's modern and reduces boilerplate versus Core Data. If you need iOS 16 support, switch to Core Data. For this tutorial we assume iOS 17+ and SwiftData.
Files & locations
App/
├─ NotesApp.swift
Models/
├─ Note+Model.swift
Persistence/
├─ DataStack.swift
Networking/
├─ APIClient.swift
├─ NotesAPI.swift
ViewModels/
├─ NotesViewModel.swift
Views/
├─ NotesListView.swift
├─ NoteEditorView.swift
Code — Model (SwiftData)
import SwiftData
@Model
final class Note {
var id: UUID
var serverId: String?
var title: String
var body: String
var updatedAt: Date
var isDirty: Bool
init(id: UUID = .init(), serverId: String? = nil, title: String, body: String, updatedAt: Date = .init(), isDirty: Bool = true) {
self.id = id
self.serverId = serverId
self.title = title
self.body = body
self.updatedAt = updatedAt
self.isDirty = isDirty
}
}
Note: isDirty marks local changes needing sync.
Code — Networking (APIClient + NotesAPI)
import Foundation
enum APIError: Error {
case network(Error)
case server(statusCode: Int)
case decoding(Error)
case unknown
}
struct APIClient {
let baseURL: URL
let session: URLSession
init(baseURL: URL, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
}
func request(_ endpoint: URLRequest) async throws -> T {
do {
let (data, response) = try await session.data(for: endpoint)
guard let http = response as? HTTPURLResponse else { throw APIError.unknown }
guard 200..<300 ~= http.statusCode else { throw APIError.server(statusCode: http.statusCode) }
do { return try JSONDecoder().decode(T.self, from: data) }
catch { throw APIError.decoding(error) }
} catch { throw APIError.network(error) }
}
}
struct NotesAPI {
let client: APIClient
struct NoteDTO: Codable {
var id: String
var title: String
var body: String
var updatedAt: Date
}
func fetchNotes() async throws -> [NoteDTO] {
let url = client.baseURL.appendingPathComponent("/notes")
var req = URLRequest(url: url)
req.httpMethod = "GET"
return try await client.request(req)
}
func upsertNote(_ note: NoteDTO) async throws -> NoteDTO {
let url = client.baseURL.appendingPathComponent("/notes")
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.addValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(note)
return try await client.request(req)
}
}
Code — NotesViewModel (sync logic)
import Foundation
import SwiftData
import Combine
@MainActor
final class NotesViewModel: ObservableObject {
@Published var isSyncing = false
private let modelContext: ModelContext
private let api: NotesAPI
init(modelContext: ModelContext, api: NotesAPI) {
self.modelContext = modelContext
self.api = api
}
func createNote(title: String, body: String) {
let note = Note(title: title, body: body)
modelContext.insert(note)
try? modelContext.save()
Task { await self.syncIfNeeded() }
}
func updateNote(_ note: Note, title: String, body: String) {
note.title = title
note.body = body
note.updatedAt = Date()
note.isDirty = true
try? modelContext.save()
Task { await self.syncIfNeeded() }
}
func syncIfNeeded() async {
guard !isSyncing else { return }
isSyncing = true
defer { isSyncing = false }
// 1. Push local dirty notes
let request: FetchDescriptor = FetchDescriptor(predicate: #Predicate { $0.isDirty == true })
let dirtyNotes = try? modelContext.fetch(request)
if let dirtyNotes = dirtyNotes, !dirtyNotes.isEmpty {
for local in dirtyNotes {
let dto = NotesAPI.NoteDTO(id: local.serverId ?? local.id.uuidString, title: local.title, body: local.body, updatedAt: local.updatedAt)
do {
let remote = try await api.upsertNote(dto)
// reconcile
local.serverId = remote.id
local.updatedAt = remote.updatedAt
local.isDirty = false
} catch {
// retry later, log error
}
}
try? modelContext.save()
}
// 2. Pull remote changes
do {
let remoteNotes = try await api.fetchNotes()
for remote in remoteNotes {
let fetch: FetchDescriptor = FetchDescriptor(predicate: #Predicate { $0.serverId == remote.id })
if let existing = try? modelContext.fetch(fetch), let local = existing.first {
// if remote newer, overwrite
if remote.updatedAt > local.updatedAt {
local.title = remote.title
local.body = remote.body
local.updatedAt = remote.updatedAt
local.isDirty = false
}
} else {
// create locally
let note = Note(id: UUID(), serverId: remote.id, title: remote.title, body: remote.body, updatedAt: remote.updatedAt, isDirty: false)
modelContext.insert(note)
}
}
try? modelContext.save()
} catch {
// handle fetch error
}
}
}
Code — NotesListView (SwiftUI)
import SwiftUI
import SwiftData
struct NotesListView: View {
@Environment(
\._modelContext
) private var modelContext
@Query(sort: [\.updatedAt], order: .reverse) private var notes: [Note]
@StateObject private var vm: NotesViewModel
init() {
// vm needs ModelContext and API; we'll create in body using a convenience initializer in App
_vm = StateObject(wrappedValue: NotesViewModel(modelContext: ModelContext(.inMemory), api: NotesAPI(client: APIClient(baseURL: URL(string: "https://api.example.com")!))))
}
var body: some View {
NavigationStack {
List {
ForEach(notes) { note in
NavigationLink(value: note) {
VStack(alignment: .leading) {
Text(note.title).font(.headline)
Text(note.body).lineLimit(2).font(.subheadline).foregroundColor(.secondary)
}
}
}
}
.navigationTitle("Notes")
.toolbar { Button("New") { vm.createNote(title: "Untitled", body: "") } }
.refreshable { await vm.syncIfNeeded() }
}
}
}
Note: In real app, wire the ViewModel initialized with the app ModelContext and real API client in the App entry.
Key considerations & improvements
- Retry & backoff — Add exponential backoff for network failures and background retry scheduling.
- Conflict resolution — This tutorial uses last-write-wins. For richer experiences, show merge UI for conflicting edits.
- Batching — Push local changes in batches instead of one-by-one.
- Security — Authenticate API calls (OAuth, JWT) and handle tokens securely.
- Testing — Unit test ViewModel sync logic by injecting a mock NotesAPI and ModelContext.
Wrap up
This end-to-end example gives you a testable, maintainable Notes Sync foundation: SwiftData for local storage, a small API layer using async/await and Codable, and a ViewModel orchestrating sync while keeping the UI responsive. Expand on this by adding background sync, robust error handling, and more sophisticated conflict resolution.
If you want, I can generate a ready-to-run Xcode sample with the App entry, DataStack, and a mock API server for local testing.