Pathrule

SwiftUI

Pathrule4 Rules • 2 Memories • 1 Skill

The modern Apple stack is narrow and opinionated: SwiftUI for views, the @Observable macro for state, SwiftData for persistence, NavigationStack for routing, and a compiler that now rejects data races instead of letting them crash at runtime. Assistants keep generating the previous generation of that stack, which either fails to build under Swift 6 or compiles into a view that reloads on every keystroke. This bundle fixes the state ownership, concurrency isolation, view body discipline, and SwiftData context rules that decide whether an app stays responsive.

Suggested path map

Pathrule places each piece on the matching path, so your assistant only sees it where it belongs. This is the scoping you get on import; you can adjust it in your workspace.

/ workspace root
swiftui-concurrency-review
Features/
One owner per piece of state, expressed with the Observable macro
Keep view bodies cheap and identities stable
Navigation is data: NavigationStack with a typed path
App/
Satisfy Swift 6 isolation instead of silencing it
Dependencies, previews, and tests without a hidden singleton
Models/
Respect SwiftData context boundaries

Rules

4
One owner per piece of state, expressed with the Observable macro/FeatureshighstrictView-local state uses State, shared state uses an Observable class on the MainActor, and no new code uses ObservableObject or published wrappers.
1SwiftUI redraws based on what a view actually reads. The Observation framework tracks that per property, which is why the old protocol-plus-wrapper approach is both more code and slower.
2 
3- Mark shared state classes with `@Observable` and annotate them `@MainActor`. Views read their properties directly; there is no `@Published`, no `objectWillChange`, and no `@StateObject`.
4- Own the instance where its lifetime belongs: `@State private var model = Model()` in the view that creates it, `@Environment` for something app-wide, and a plain `let` for a model passed in. Never create an observable model in a computed property or in `body`.
5- Use `@Bindable` when a child needs two-way access to an observable's property, and `@Binding` for value types owned by a parent.
6- Keep `@State` for genuinely view-local values (a text field's draft, a sheet flag). If two views need it, it belongs to a model, not to a parent's binding chain.
7- Do not mirror the same value in two owners and sync them in `onChange`. Derive it with a computed property instead.
Satisfy Swift 6 isolation instead of silencing it/ApphighstrictUI types stay on the MainActor, shared mutable state lives in an actor, values crossing boundaries are Sendable, and unchecked Sendable is not a fix.
1Swift 6 turns data races into compile errors. Every warning it raises is a race the old code had and nobody noticed.
2 
3- Annotate UI-facing types `@MainActor` (views already are) and let the compiler push background work outward, rather than sprinkling `DispatchQueue.main.async` at the call sites.
4- Put shared mutable state in an `actor` and reach it with `await`. A class with a lock that you promise is safe is exactly what `@unchecked Sendable` hides; use it only for a type you have genuinely audited, and write down why.
5- Make types that cross a boundary `Sendable`. Prefer value types and immutable stored properties, since that makes conformance automatic.
6- Prefer structured concurrency: `async let` and `TaskGroup` for parallel work, `.task` for view-scoped work. An unstructured `Task { }` inherits no cancellation and no isolation guarantees, so reserve it for genuine fire-and-forget and store the handle if it must be cancellable.
7- Never block a thread waiting for async work (no semaphores around `Task`), and never call a synchronous API that blocks on the main actor.
Keep view bodies cheap and identities stable/FeaturesmediumstrictA body only describes UI: no I/O, no formatting work, no sorting; lists use stable ids and async work runs in task with cancellation.
1`body` can be evaluated many times per second. Anything expensive in it is multiplied by every render, and unstable identity throws away the view state SwiftUI was preserving.
2 
3- Do no work in `body` beyond reading state and composing views. Precompute derived collections, formatted strings, and date math in the model (a computed property on an `@Observable` type is fine, a network call is not).
4- Start async work with `.task { }` (or `.task(id:)` when it should restart on a value change), never `onAppear` plus an unstructured `Task`. `.task` is cancelled automatically when the view goes away, which is what prevents the classic "update after dismiss" crash.
5- Give `ForEach` a genuinely stable identity: an `Identifiable` model or an explicit `id:` keyed to a real identifier. Using an array index reuses the wrong state when the collection reorders.
6- Extract subviews so state changes redraw the smallest region that changed, and pass the narrowest data a subview needs rather than the whole model.
7- Watch the modifier order (`frame` before `background`, `padding` before `border`), because in SwiftUI order is semantics, not style.
Respect SwiftData context boundaries/ModelshighstrictThe UI reads through the main context, writes off the main thread go through a background context or ModelActor, and identifiers cross boundaries instead of models.
1SwiftData models are not `Sendable`. A model object belongs to the context that fetched it, and moving one across an isolation boundary is the source of the crashes people blame on SwiftData being immature.
2 
3- Configure one `ModelContainer` at the app entry and inject it with `.modelContainer(...)`. Views fetch with `@Query` and mutate through `@Environment(\.modelContext)`.
4- Do bulk or long-running work in a background context (a `ModelActor`, or a context created on the actor doing the work) and save there. Do not hand a fetched model to a `Task` on another actor.
5- Pass a `PersistentIdentifier` across boundaries and re-fetch on the other side. That is the only safe way to refer to the same row from another context.
6- Keep `@Query` predicates and sort descriptors in the query itself so the store does the filtering. Fetching everything and filtering in Swift defeats the index and loads the whole table.
7- Model relationships explicitly with `@Relationship`, choose the delete rule deliberately, and mark unique constraints; SwiftData will not guess your integrity rules.
8 
9See /App for the concurrency rule this depends on.

Memories

2
Dependencies, previews, and tests without a hidden singleton/AppInject services through the environment or initialisers so previews and Swift Testing runs get fakes instead of the network.
1The main reason SwiftUI code becomes untestable is a view or model that reaches for a shared singleton. Injecting instead costs nothing and pays for itself in previews.
2 
3- Define a protocol for anything that leaves the process (network, keychain, notifications, analytics), inject it into the observable model, and default to the live implementation at the app entry.
4- Expose app-wide dependencies through a custom `EnvironmentKey` (or `@Entry`) so a view subtree can be re-pointed at a fake. Reading a global `.shared` inside a view makes every preview a live request.
5- Give each screen a `#Preview` with a fake model in a fixed state, including the loading and error states. A preview that only shows the happy path is the reason nobody notices the empty state is broken.
6- Write tests with Swift Testing (`@Test`, `#expect`, `@Suite`) for new code, and keep XCTest for UI tests and existing suites. Both can coexist in a project.
7- Test the model, not the view body: given a fake service, assert the state the view will render. Snapshot or UI tests cover layout, not logic.
8 
9See /Features for state ownership and /Models for the SwiftData boundary these fakes stand in for.

Skills

1
swiftui-concurrency-review/rootPre-merge checklist for SwiftUI changes under Swift 6 strict concurrency and SwiftData.
1---
2name: swiftui-concurrency-review
3description: Review checklist for SwiftUI code under Swift 6 strict concurrency: state ownership, actor isolation, view body cost, SwiftData context boundaries, and navigation state. Run before merging.
4---
5 
6# SwiftUI review
7 
8## State
9- [ ] Shared state is an `@Observable` `@MainActor` class; no new `ObservableObject` or `@Published`.
10- [ ] Each value has exactly one owner; no mirrored copies synced in `onChange`.
11- [ ] Models are not constructed inside `body` or a computed view property.
12 
13## Concurrency
14- [ ] No new `@unchecked Sendable`; shared mutable state is in an actor.
15- [ ] Async work uses `.task` (or `.task(id:)`) and is cancellable; no detached `Task` for view work.
16- [ ] Nothing blocks a thread waiting on async work; no semaphores around `Task`.
17- [ ] The build is clean under strict concurrency, with no suppressed warnings.
18 
19## Views
20- [ ] `body` does no I/O, formatting, or sorting.
21- [ ] `ForEach` uses a stable identifier, not an array index.
22- [ ] Subviews are extracted so a change redraws the smallest region.
23 
24## SwiftData
25- [ ] Models never cross an isolation boundary; `PersistentIdentifier` does.
26- [ ] Bulk writes run in a background context or `ModelActor`.
27- [ ] `@Query` filters and sorts in the query, not in Swift.
28 
29## Navigation
30- [ ] Routing is a typed path array owned by a model; no `NavigationView`.
31- [ ] Deep link and restoration go through the same path decode.

Why this pattern

AI agents write SwiftUI with ObservableObject and published properties, do work inside view bodies, and cross actor boundaries in ways Swift 6 strict concurrency rejects.

Built for iOS and macOS teams on Swift 6 with SwiftUI and SwiftData.

Keeps your assistant from:

  • Generating ObservableObject and published wrappers where the Observable macro is the current API
  • Doing network or formatting work inside a view body so it reruns on every render
  • Passing a SwiftData model between actors instead of its persistent identifier
  • Silencing a concurrency error with unchecked Sendable instead of fixing the isolation
License
Apache-2.0
Version
1.0.0
Updated
2026-08-24
View source