Start a macOS app
Separate model, state, and views
Give durable data, interface state, and rendered views clear ownership before the project grows.
12 minute lesson
Before the app grows, decide who owns what. Every messy SwiftUI codebase I have seen got messy the same way: views quietly accumulated data storage, file access, and business rules until nobody could test anything.
Three kinds of things live in a SwiftUI app. Model data is what the app is about — the notes. Interface state is what the UI is doing right now — which note is selected, whether a sheet is open. Views render the first two and send actions back.
Start with a value type for the model:
struct Note: Identifiable, Codable {
let id: UUID
var title: String
var body: String
}
A struct is the right default here. It is Identifiable so lists can track rows, and Codable so we can save it to disk later without extra work.
The collection of notes needs a single owner that views can observe:
@MainActor
final class NotesModel: ObservableObject {
@Published var notes: [Note] = []
func createNote() {
notes.append(Note(id: UUID(), title: "New note", body: ""))
}
}
Create one instance at the app level and inject it:
@main
struct NotesApp: App {
@StateObject private var model = NotesModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
}
}
}
@StateObject makes the app own the model’s lifetime. Views read it with @EnvironmentObject and call methods like createNote() instead of mutating arrays directly. The method names become the vocabulary of what your app can do.
Interface state stays local. The selected note ID or a “show settings sheet” flag belongs in @State inside the view that uses it. If you push every ephemeral flag into the shared model, every window of your app will fight over the same selection.
The payoff shows up in testing. NotesModel is a plain class. You can create one in a unit test, call createNote(), and assert on notes — no window, no simulator, no waiting. If your important behavior can only be tested by clicking through the app, the ownership boundaries are in the wrong place.
Lesson completed