Make the app reliable

Cross concurrency boundaries deliberately

Keep interface state on the main actor and move blocking file or process work behind asynchronous service methods.

12 minute lesson

~~~

Everything the user sees lives on the main actor — one protected context that owns all interface state. Blocking file reads and child-process waits do not belong there, because while the main actor is busy, the app is beachballing.

The design that works: interface state on the main actor, slow work in async services, and explicit hops between them.

Mark the model with @MainActor:

@MainActor
final class ExportModel: ObservableObject {
  @Published private(set) var status = "Ready"

  func export() async {
    status = "Exporting"
    let result = await exporter.run()
    status = result.summary
  }
}

Read export() as a story about threads. The first status assignment runs on the main actor. The await is the boundary: the model suspends, exporter.run() does its slow work wherever it likes, and — this is the part Swift handles for you — the method resumes on the main actor for the final assignment. No dispatch calls, no queue names, and the compiler checks it.

The service side stays free of UI concerns:

struct ExportResult {
  let summary: String
}

struct Exporter {
  func run() async -> ExportResult {
    // launch the child process, await its exit
    ExportResult(summary: "Exported 3 notes")
  }
}

Services return values or throw typed errors. They never reach back into the model, never touch @Published properties, never “helpfully” dispatch to the main queue. The model pulls results across the boundary. The service does not push.

Here is the mistake and how it announces itself. Update status from a background context — a DispatchQueue.global().async block, a callback from an old-style API — and Xcode prints a purple runtime warning: publishing changes from background threads is not allowed. Sometimes the UI still updates and everything looks fine. Do not trust that. It is a data race that happens to be working today, and it will eventually show up as a stale label or a crash you cannot reproduce. Treat every purple warning as a bug found early. With strict concurrency checking enabled — and it is worth enabling — many of these mistakes stop compiling at all.

Cancellation deserves a decision, not an afterthought. When the user cancels an export, Task.cancel() only sets a flag. Your service must check Task.isCancelled at sensible points and decide what happens to partial output — delete the half-file, or keep it and mark it. Either is defensible, but choose.

Verify by feel: run a big export and click a menu while it runs. The window must stay responsive. If the beachball appears, something blocking snuck onto the main actor — the usual suspect is a synchronous waitUntilExit called outside the service.

Lesson completed

Take this course offline

Get every free book and course as PDF and EPUB files.

Get the download library →