Start a macOS app
Separate model, state, and views
Give durable data, interface state, and rendered views clear ownership before the project grows.
Before the app grows, decide who owns what. Every messy SwiftUI codebase I’ve seen got messy the same way. Views quietly picked up 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. For us, 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.
The model
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’s 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: ""))
}
}
@Published tells SwiftUI to refresh any view reading notes when the array changes. @MainActor keeps every change on the main thread, where the UI lives.
Inject it once
Create one instance at the app level and pass it down:
@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 the array directly. Those 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.
Don’t push every short-lived flag into the shared model. If you do, every window of your app will fight over the same selection. Remember, WindowGroup can open several windows, and each one should keep its own.
Why this pays off
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.
My rule of thumb: if your important behavior can only be tested by clicking through the app, the ownership boundaries are in the wrong place. Move logic into the model until the test is a few lines long.
Lesson completed