Bryn Flow

🍎 Build a Notes App with SwiftUI and Core Data

From an empty Xcode project to a working, persisted notes app — SwiftUI for the UI, Core Data for storage, and @FetchRequest tying them together live.

Beginner–Intermediate ⏱️ ~40 minutes 🧰 Swift, SwiftUI, Core Data
← iOS Track

What You'll Build

A single-screen notes list with a "+" toolbar button that presents a sheet for adding a note, swipe-to-delete, and Core Data persistence so notes are still there after a relaunch.

Prerequisites

  • Xcode with a new project created using the "App" template, "Use Core Data" checked (or add the stack manually as shown below)
  • Basic Swift syntax — variables, structs, functions
  • Helpful but not required: read SwiftUI Views & Modifiers and Core Data Basics first

Step by Step

1 Define the Note entity in the data model

Open your .xcdatamodeld file (or create one), add an entity named Note, and give it two attributes: title (String) and body (String), plus createdAt (Date). Xcode generates a matching NSManagedObject subclass automatically.

// Entity: Note
// Attributes: title (String), body (String), createdAt (Date)
// Codegen: Class Definition (default) — Xcode generates the Note class for you

2 Set up the persistence controller

If your project wasn't created with "Use Core Data" checked, add this small controller that owns the NSPersistentContainer.

// Persistence.swift
import CoreData

struct PersistenceController {
    static let shared = PersistenceController()

    let container: NSPersistentContainer

    init() {
        container = NSPersistentContainer(name: "NotesModel") // matches .xcdatamodeld file name
        container.loadPersistentStores { _, error in
            if let error { fatalError("Core Data failed to load: \(error)") }
        }
    }
}

Inject the context into the SwiftUI environment in your App struct:

// NotesApp.swift
@main
struct NotesApp: App {
    let persistence = PersistenceController.shared

    var body: some Scene {
        WindowGroup {
            NotesListView()
                .environment(\.managedObjectContext, persistence.container.viewContext)
        }
    }
}

3 Fetch and display notes with @FetchRequest

@FetchRequest binds a live Core Data query directly to a SwiftUI view — no manual refresh needed, the list updates automatically whenever the store changes.

// NotesListView.swift
import SwiftUI
import CoreData

struct NotesListView: View {
    @Environment(\.managedObjectContext) private var context

    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Note.createdAt, ascending: false)]
    ) private var notes: FetchedResults<Note>

    @State private var showingAddNote = false

    var body: some View {
        NavigationStack {
            Group {
                if notes.isEmpty {
                    ContentUnavailableView("No notes yet", systemImage: "note.text", description: Text("Tap + to add one"))
                } else {
                    List {
                        ForEach(notes) { note in
                            VStack(alignment: .leading) {
                                Text(note.title ?? "").font(.headline)
                                Text(note.body ?? "").font(.subheadline).foregroundColor(.secondary)
                            }
                        }
                        .onDelete(perform: deleteNotes)
                    }
                }
            }
            .navigationTitle("My Notes")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button(action: { showingAddNote = true }) {
                        Image(systemName: "plus")
                    }
                }
            }
            .sheet(isPresented: $showingAddNote) {
                AddNoteView()
            }
        }
    }

    private func deleteNotes(at offsets: IndexSet) {
        offsets.map { notes[$0] }.forEach(context.delete)
        try? context.save()
    }
}

4 Build the "Add Note" sheet

// AddNoteView.swift
struct AddNoteView: View {
    @Environment(\.managedObjectContext) private var context
    @Environment(\.dismiss) private var dismiss

    @State private var title = ""
    @State private var body = ""

    var body: some View {
        NavigationStack {
            Form {
                TextField("Title", text: $title)
                TextField("Body", text: $body)
            }
            .navigationTitle("New Note")
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") { save() }.disabled(title.isEmpty)
                }
            }
        }
    }

    private func save() {
        let note = Note(context: context)
        note.title = title
        note.body = body
        note.createdAt = Date()
        try? context.save()
        dismiss()
    }
}

Because @FetchRequest observes the context, saving here immediately updates the list back on NotesListView — there's no callback or notification to wire up manually.

5 Run it

Build and run. Add a couple of notes, delete one with swipe-to-delete, then stop and relaunch the app — your remaining notes should still be there, proving Core Data persisted them to disk.

Final Working Code

The data model (Note entity), Persistence.swift, NotesApp.swift, NotesListView.swift, and AddNoteView.swift above together form the complete app exactly as written — create each file with a matching name and it will build as-is.

What to Try Next

Want more iOS walkthroughs?

Head back to the iOS track for the full topic list, roadmap, and practice quiz.

Back to iOS Track