Swift and SwiftUI rules for AI coding agents
SwiftUI state is where agents get confused fastest, because the right property wrapper depends on who owns the data and on which observation system the project uses. The Swift rules cover Swift 6 and SwiftUI apps, and with the Observation framework they describe the pattern plainly: own a model with @State, pass it as a plain property, use @Bindable when a child needs bindings.
They also keep body cheap and free of side effects, gate newer APIs with if #available instead of quietly raising the deployment target, ban force unwraps, try! and as! in app code, and send tokens to the Keychain rather than UserDefaults. Concurrency rules cover @MainActor, Sendable and task cancellation for the Swift 6 language mode.
Folder: rules/swift-swiftui/ · swift-swiftui.mdc, CLAUDE.md, AGENTS.md
What the SwiftUI rules tell your agent
- VersionsCheck the deployment target and Swift language mode in the project (or
Package.swiftswift-tools-version). Gate newer APIs withif #available(iOS 18, *)instead of raising the target silently. - State and data flowWith the Observation framework (
@Observable, iOS 17+): own a model with@State, pass it as a plain property, use@Bindablewhen a child needs bindings, and share it with.environment(model)/@Environment(Model.self). - Views
bodymust be cheap and free of side effects. It runs often. No network calls, no date formatter creation, no sorting large arrays inline. - Errors and safetyNo force unwraps (
!),try!oras!in app code. Useguard let,if let,trywithdo/catch, andas?. - Security and privacyStore tokens and passwords in the Keychain, never in
UserDefaults, plist files or source.
First rule of each section in swift-swiftui.mdc, quoted as shipped.
Mistakes these rules are written to prevent
- UI types and view models that drive UI are
@MainActor. Do not useDispatchQueue.main.asyncin async code; hop withawait MainActor.runor annotate the function. - Use
async letor aTaskGroupfor parallel work, and checkTask.isCancelled(or calltry Task.checkCancellation()) in long loops. - Types sent across actors must be
Sendable. Prefer immutable structs; isolate mutable shared state in anactor.
Source: “Concurrency”.
Checks the agent runs before it says done
Example commands from the SwiftUI file (swap in your repo's own):
- Build and test:
xcodebuild -scheme <App> -destination 'platform=iOS Simulator,name=<device>' build test - Packages:
swift build && swift test
Build for the simulator (xcodebuild -scheme <App> -destination 'platform=iOS Simulator,name=<device>' build test) or swift build && swift test for packages, and report warnings as well as errors.
Before and after: SwiftUI: state ownership review
Prompt card 26 · Stack: Swift, SwiftUI (iOS 17+ Observation or ObservableObject) · Type: Review and refactor · Best model: a frontier reasoning model
What the card is for
Find view models recreated on every parent update, duplicated sources of truth, @State initialized from props that never update, and async work tied to the wrong lifecycle. These are the usual causes of "my screen resets" and "data loads twice" bugs.
Worked example from the card
Before
struct OrdersScreen: View {
let customerID: Customer.ID
@ObservedObject var model = OrdersModel()
@State private var filter = ""
var body: some View {
List(model.orders.filter { filter.isEmpty || $0.title.contains(filter) }) { order in
Text(order.title)
Text(DateFormatter.localizedString(from: order.date, dateStyle: .medium, timeStyle: .none))
}
.searchable(text: $filter)
.onAppear { Task { await model.load(customerID) } }
}
}
Expected AI output (abridged)
| model | nobody (recreated) | body | load() | @ObservedObject with an initial value:
| | | | | recreated whenever the parent re-renders;
| | | | | orders vanish and load again.
| filter | OrdersScreen | body | search | fine
| load | - | - | - | Task in onAppear is not canceled, and does
| | | | | not rerun when customerID changes.
Fix: own the model with @State (Observable, iOS 17+) or @StateObject; use
.task(id: customerID); use Text(date, format:) instead of building strings in body.
After
@Observable @MainActor
final class OrdersModel {
private(set) var orders: [Order] = []
var error: Error?
func load(_ id: Customer.ID) async {
do { orders = try await api.orders(for: id) } catch is CancellationError { } catch { self.error = error }
}
}
struct OrdersScreen: View {
let customerID: Customer.ID
@State private var model = OrdersModel()
@State private var filter = ""
private var visible: [Order] {
filter.isEmpty ? model.orders : model.orders.filter { $0.title.localizedStandardContains(filter) }
}
var body: some View {
List(visible) { order in
Text(order.title)
Text(order.date, format: .dateTime.day().month().year())
}
.searchable(text: $filter)
.task(id: customerID) { await model.load(customerID) }
}
}
Excerpt from card 26; the prompt itself is in the pack.
More SwiftUI prompt cards in the pack
- Card 27: Swift 6: fix strict-concurrency errors properly
Using these rules in Cursor, Claude Code and AGENTS.md tools
Cursor
Put _base.mdc and swift-swiftui.mdc in .cursor/rules/. Globs: Package.swift, **/*.swift.
Claude Code
Root CLAUDE.md, base first. Path-scoped variant .claude/rules/swift-swiftui.md for **/*.swift; PostToolUse hook: swift format --in-place <file> (or swiftformat, if configured).
AGENTS.md tools (Codex, Gemini CLI, Aider)
Base then stack in one AGENTS.md. Gemini CLI needs context.fileName; Aider needs --read AGENTS.md. Put this file in the folder with the .xcodeproj or Package.swift. Agents use the AGENTS.md nearest to the file being edited, so a nested copy overrides a root one for that subtree.