Why local storage matters for a notes app
You build a notes feature that must store titles, body text, tags, creation date, and optional images. The data size is small to medium. You expect queries by tag and sorting by date. You want a path to sync later. Choose a persistence approach that fits this need.
Choose between UserDefaults, Core Data, and SwiftData
UserDefaults
UserDefaults stores small bits of user state. It is not for collections of domain objects. You will lose query power and performance with many notes. Use UserDefaults only for preferences and flags.
Core Data
Core Data offers full object graph management and mature tools. It is robust for large datasets and complex relationships. It requires more boilerplate and careful setup. If you need fine tuned performance or heavy customization, choose Core Data.
SwiftData
SwiftData is a modern layer built for Swift and SwiftUI. It keeps much of Core Data power while making models and queries simple. For a notes app with moderate complexity and future sync plans, SwiftData offers the best tradeoff of ergonomics and power.
Decision
Use SwiftData. It provides typed models, SwiftUI friendly tools, and enough power for queries and relationships. It reduces boilerplate compared to Core Data. It scales well for the use case described.
High level design
- Store note metadata and text in SwiftData.
- Store image files on disk in Application Support. Save the image filename in the model. This avoids bloating the database and eases backup choices.
- Expose a lightweight repository layer. Keep file IO isolated and synchronous where appropriate, or run it on a background queue for large writes.
- Use @Query for lists in SwiftUI views. Insert and save via the ModelContext.
Step-by-step implementation
1. Define the model
Create a SwiftData model for notes. Include an id, title, body, tags, createdAt, and an optional image filename.
import SwiftData
@Model
final class Note {
@Attribute(.unique) var id: UUID
var title: String
var body: String
var tags: [String]
var createdAt: Date
var imageFilename: String?
init(
id: UUID = UUID(),
title: String,
body: String = "",
tags: [String] = [],
createdAt: Date = Date(),
imageFilename: String? = nil
) {
self.id = id
self.title = title
self.body = body
self.tags = tags
self.createdAt = createdAt
self.imageFilename = imageFilename
}
}
2. Configure the model container
Wire the container into your app so views receive a ModelContext.
import SwiftUI
import SwiftData
@main
struct NotesApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.modelContainer(for: [Note.self])
}
}
}
3. File helpers for images
Store images in Application Support. Exclude files from backups when they are recreatable. Use a helper that saves and loads by filename. Use UUID-based filenames to avoid collisions.
import UIKit
struct ImageStore {
static let directoryName = "NoteImages"
static func imagesDirectory() throws -> URL {
let fm = FileManager.default
let urls = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask)
let base = urls[0]
let dir = base.appendingPathComponent(directoryName)
if !fm.fileExists(atPath: dir.path) {
try fm.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
}
return dir
}
static func save(image: UIImage, filename: String) throws -> URL {
let dir = try imagesDirectory()
let url = dir.appendingPathComponent(filename)
guard let data = image.jpegData(compressionQuality: 0.85) else {
throw NSError(domain: "ImageStore", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid image data"])
}
try data.write(to: url, options: .atomic)
return url
}
static func load(filename: String) -> UIImage? {
do {
let dir = try imagesDirectory()
let url = dir.appendingPathComponent(filename)
let data = try Data(contentsOf: url)
return UIImage(data: data)
} catch {
return nil
}
}
static func remove(filename: String) {
do {
let dir = try imagesDirectory()
let url = dir.appendingPathComponent(filename)
try FileManager.default.removeItem(at: url)
} catch {
// ignore missing file
}
}
}
4. Repository and CRUD
Keep data logic in a repository. Inject ModelContext. Save image files first, then save the model. Use error handling and background queues for heavy IO.
import SwiftData
import UIKit
final class NoteRepository {
let context: ModelContext
init(context: ModelContext) {
self.context = context
}
func add(title: String, body: String, tags: [String], image: UIImage?) throws {
var imageFilename: String? = nil
if let image = image {
let filename = UUID().uuidString + ".jpg"
_ = try ImageStore.save(image: image, filename: filename)
imageFilename = filename
}
let note = Note(title: title, body: body, tags: tags, imageFilename: imageFilename)
context.insert(note)
try context.save()
}
func update(note: Note, title: String, body: String, tags: [String], image: UIImage?) throws {
if let newImage = image {
if let oldFilename = note.imageFilename {
ImageStore.remove(filename: oldFilename)
}
let filename = UUID().uuidString + ".jpg"
_ = try ImageStore.save(image: newImage, filename: filename)
note.imageFilename = filename
}
note.title = title
note.body = body
note.tags = tags
try context.save()
}
func delete(_ note: Note) throws {
if let filename = note.imageFilename {
ImageStore.remove(filename: filename)
}
context.delete(note)
try context.save()
}
}
5. Querying in SwiftUI
Use @Query for simple lists and filters. Add a filtered query by tag where needed.
import SwiftUI
import SwiftData
struct NotesListView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Note.createdAt, order: .reverse) private var notes: [Note]
var body: some View {
List(notes) { note in
VStack(alignment: .leading) {
Text(note.title)
.font(.headline)
Text(note.body)
.font(.subheadline)
.lineLimit(2)
}
}
.navigationTitle("Notes")
}
}
Production tips
- Keep images on disk. This prevents database bloat and speeds up model queries.
- Use atomic writes for image saves and model saves. This reduces corruption risk.
- Graceful error handling. Surface errors to the user with retry options.
- Background IO. Run large image writes on a background queue to avoid blocking the main thread.
- Migration path. SwiftData includes migration support. Test upgrades on real data sets.
- Prepare for sync. Structure models with stable ids and simple fields. That eases later integration with CloudKit or a backend.
Testing and debugging
Write unit tests for the repository. Use an in-memory model container for tests. Verify file cleanup on delete. Validate queries return expected sorting and filtering.
In-memory container example
import SwiftData
let container = try ModelContainer(for: [Note.self], configurations: .init(storeDescriptions: [ModelStoreDescription(inMemory: true)]))
let context = container.mainContext
// Use this context in tests
Wrap up
For a notes app with tags and optional images, SwiftData offers the best balance of simplicity and power. Store text and metadata in SwiftData. Store images on disk and save filenames in the model. Keep IO isolated and test the repository. This yields a robust, maintainable local storage layer that scales well and keeps future sync options open.