
Swift Refactor
- 219 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
swift-refactor: A skill for development. This provides functionality for development workflows.
Key points
- swift-refactor
Swift Refactor by the numbers
- 219 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,825 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill swift-refactorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 219 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use swift-refactor for development tasks?
Use swift-refactor for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with swift-refactor.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use swift-refactor for development tasks, or when swift-refactor: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to swift-refactor: swift-refactor.
Files
Swift/SwiftUI Refactor (Modular MVVM-C)
Comprehensive refactoring guide for migrating Swift/SwiftUI code to modular MVVM-C with local SPM package boundaries and App-target composition root wiring.
Mandated Architecture Stack
┌───────────────────────────────────────────────────────────────┐
│ App target: DependencyContainer, Coordinators, Route Shells │
├───────────────────────────────────────────────────────────────┤
│ Feature modules: View + ViewModel (Domain + DesignSystem deps)│
├───────────────────────────────────────────────────────────────┤
│ Data package: repositories, remote/local stores, sync, retry │
├───────────────────────────────────────────────────────────────┤
│ Domain package: models, repository/coordinator/error protocols │
└───────────────────────────────────────────────────────────────┘Dependency Rule: Feature modules never import Data and never import sibling features.
Clinic Architecture Contract (iOS 26 / Swift 6.2)
All guidance in this skill assumes the clinic modular MVVM-C architecture:
- Feature modules import
Domain+DesignSystemonly (neverData, never sibling features) - App target is the convergence point and owns
DependencyContainer, concrete coordinators, and Route Shell wiring Domainstays pure Swift and defines models plus repository,*Coordinating,ErrorRouting, andAppErrorcontractsDataowns SwiftData/network/sync/retry/background I/O and implements Domain protocols- Read/write flow defaults to stale-while-revalidate reads and optimistic queued writes
- ViewModels call repository protocols directly (no default use-case/interactor layer)
When to Apply
Reference these guidelines when:
- Migrating from deprecated SwiftUI APIs (ObservableObject, NavigationView, old onChange)
- Restructuring state management to use @Observable ViewModels
- Adding @Equatable diffing to views for performance
- Decomposing large views into 10-node maximum bodies
- Refactoring navigation to coordinator + route shell pattern
- Refactoring to Domain/Data/Feature/App package boundaries
- Setting up dependency injection through
DependencyContainer - Improving list/collection scroll performance
- Replacing manual Task management with
.task(id:)and cancellable loading
Non-Negotiable Constraints (iOS 26 / Swift 6.2)
@ObservableViewModels/coordinators,ObservableObject/@PublishedneverNavigationStackis owned by App-target route shells and coordinators@Equatablemacro on every view,AnyViewnever- Domain defines repository/coordinator/error-routing protocols; no framework-coupled I/O
- No dedicated use-case/interactor layer; ViewModels call repository protocols directly
- Views never access repositories directly
Rule Categories by Priority
| Priority | Category | Impact | Prefix | Rules |
|---|---|---|---|---|
| 1 | View Identity & Diffing | CRITICAL | diff- | 4 |
| 2 | API Modernization | CRITICAL | api- | 7 |
| 3 | State Architecture | CRITICAL | state- | 6 |
| 4 | View Composition | HIGH | view- | 7 |
| 5 | Navigation & Coordination | HIGH | nav- | 5 |
| 6 | Layer Architecture | HIGH | layer- | 5 |
| 7 | Architecture Patterns | HIGH | arch- | 5 |
| 8 | Dependency Injection | MEDIUM-HIGH | di- | 2 |
| 9 | Type Safety & Protocols | MEDIUM-HIGH | type- | 4 |
| 10 | List & Collection Performance | MEDIUM | list- | 4 |
| 11 | Async & Data Flow | MEDIUM | data- | 3 |
| 12 | Swift Language Fundamentals | MEDIUM | swift- | 8 |
Quick Reference
1. View Identity & Diffing (CRITICAL)
- `diff-equatable-views` - Add @Equatable macro to every SwiftUI view
- `diff-closure-skip` - Use @EquatableIgnored for closure properties
- `diff-identity-stability` - Use stable O(1) identifiers in ForEach
- `diff-printchanges-debug` - Use _printChanges() to diagnose re-renders
2. API Modernization (CRITICAL)
- `api-observable-macro` - Migrate ObservableObject to @Observable macro
- `api-navigationstack-migration` - Replace NavigationView with NavigationStack
- `api-onchange-signature` - Migrate to new onChange signature
- `api-environment-object-removal` - Replace @EnvironmentObject with @Environment
- `api-alert-confirmation-dialog` - Migrate Alert to confirmationDialog API
- `api-list-foreach-identifiable` - Replace id: \.self with Identifiable conformance
- `api-toolbar-migration` - Replace navigationBarItems with toolbar modifier
3. State Architecture (CRITICAL)
- `state-scope-minimization` - Minimize state scope to nearest consumer
- `state-derived-over-stored` - Use computed properties over redundant @State
- `state-binding-extraction` - Extract @Binding to isolate child re-renders
- `state-remove-observation` - Migrate @ObservedObject to @Observable tracking
- `state-onappear-to-task` - Replace onAppear closures with .task modifier
- `state-stateobject-placement` - Migrate @StateObject to @State with @Observable
4. View Composition (HIGH)
- `view-extract-subviews` - Extract subviews for diffing checkpoints
- `view-eliminate-anyview` - Replace AnyView with @ViewBuilder or generics
- `view-computed-to-struct` - Convert computed view properties to struct views
- `view-modifier-extraction` - Extract repeated modifiers into custom ViewModifiers
- `view-conditional-content` - Use Group or conditional modifiers over conditional views
- `view-preference-keys` - Replace callback closures with PreferenceKey
- `view-body-complexity` - Reduce view body to maximum 10 nodes
5. Navigation & Coordination (HIGH)
- `nav-centralize-destinations` - Refactor navigation to coordinator pattern
- `nav-value-based-links` - Replace NavigationLink with coordinator routes
- `nav-path-state-management` - Use NavigationPath for programmatic navigation
- `nav-split-view-adoption` - Use NavigationSplitView for multi-column layouts
- `nav-sheet-item-pattern` - Replace boolean sheet triggers with item binding
6. Layer Architecture (HIGH)
- `layer-dependency-rule` - Extract domain layer with zero framework imports
- `layer-usecase-protocol` - Remove use-case/interactor layer; keep orchestration in ViewModel + repository protocols
- `layer-repository-protocol` - Repository protocols in Domain, implementations in Data
- `layer-no-view-repository` - Remove direct repository access from views
- `layer-viewmodel-boundary` - Refactor ViewModels to expose display-ready state only
7. Architecture Patterns (HIGH)
- `arch-viewmodel-elimination` - Restructure inline state into @Observable ViewModel
- `arch-protocol-dependencies` - Extract protocol dependencies through ViewModel layer
- `arch-environment-key-injection` - Use Environment keys for service injection
- `arch-feature-module-extraction` - Extract features into independent modules
- `arch-model-view-separation` - Extract business logic into Domain models and repository-backed ViewModels
8. Dependency Injection (MEDIUM-HIGH)
- `di-container-composition` - Compose dependency container at app root
- `di-mock-testing` - Add mock implementation for every protocol dependency
9. Type Safety & Protocols (MEDIUM-HIGH)
- `type-tagged-identifiers` - Replace String IDs with tagged types
- `type-result-over-optionals` - Use Result type over optional with error flag
- `type-phantom-types` - Use phantom types for compile-time state machines
- `type-force-unwrap-elimination` - Eliminate force unwraps with safe alternatives
10. List & Collection Performance (MEDIUM)
- `list-constant-viewcount` - Ensure ForEach produces constant view count per element
- `list-filter-in-model` - Move filter/sort logic from ForEach into ViewModel
- `list-lazy-stacks` - Replace VStack/HStack with Lazy variants for unbounded content
- `list-id-keypath` - Provide explicit id keyPath — never rely on implicit identity
11. Async & Data Flow (MEDIUM)
- `data-task-modifier` - Replace onAppear async work with .task modifier
- `data-error-loadable` - Model loading states as enum instead of boolean flags
- `data-cancellation` - Use .task automatic cancellation — never manage Tasks manually
12. Swift Language Fundamentals (MEDIUM)
- `swift-let-vs-var` - Use let for constants, var for variables
- `swift-structs-vs-classes` - Prefer structs over classes
- `swift-camel-case-naming` - Use camelCase naming convention
- `swift-string-interpolation` - Use string interpolation for dynamic text
- `swift-functions-clear-names` - Name functions and parameters for clarity
- `swift-for-in-loops` - Use for-in loops for collections
- `swift-optionals` - Handle optionals safely with unwrapping
- `swift-closures` - Use closures for inline functions
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
Rule Title Here
Brief explanation of WHY this matters and the performance/quality implications. Keep to 1-3 sentences.
Incorrect (description of the problem/cost):
// Production-realistic bad code example
// Include comment explaining the consequence
struct ExampleView: View {
var body: some View {
Text("Example")
}
}Correct (description of the benefit/solution):
// Production-realistic good code example
// Minimal diff from incorrect version
struct ExampleView: View {
var body: some View {
Text("Example")
}
}Alternative (when to use this approach):
// Optional: alternative approach for different contextsWhen NOT to use this pattern:
- Exception case 1
- Exception case 2
Reference: Documentation Title
{
"version": "1.0.7",
"date": "2026-02-16",
"technology": "Swift/SwiftUI (iOS 26 / Swift 6.2)",
"organization": "Airbnb",
"abstract": "Refactoring patterns aligned with the iOS 26 / Swift 6.2 modular MVVM-C architecture. Covers @Observable migration, @Equatable diffing, App-target coordinator + route-shell navigation, Domain/Data/Feature package boundaries, and modern SwiftUI APIs. Aligned with the iOS 26 / Swift 6.2 clinic modular MVVM-C architecture.",
"references": [
"https://airbnb.tech/uncategorized/understanding-and-improving-swiftui-performance/",
"https://developer.apple.com/wwdc23/10149",
"https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro",
"https://nalexn.github.io/clean-architecture-swiftui/",
"https://www.kodeco.com/books/advanced-ios-app-architecture"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. View Identity & Diffing (diff)
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Impact: CRITICAL Description: Non-diffable view properties cause full-tree re-evaluation on every state change. Airbnb measured a 15% reduction in scroll hitches after enforcing @Equatable and proper diffing — this is the single highest-impact SwiftUI optimization.
2. API Modernization (api)
Impact: CRITICAL Description: Migrating from deprecated APIs (@ObservableObject → @Observable, NavigationView → NavigationStack, old onChange) prevents future breakage and unlocks modern SwiftUI performance.
3. State Architecture (state)
Impact: CRITICAL Description: Wrong state ownership (@State vs plain property vs @Environment) cascades unnecessary rebuilds. @Observable scoping and single-source-of-truth violations multiply render cost geometrically.
4. View Composition (view)
Impact: HIGH Description: Monolithic view bodies prevent SwiftUI's diff engine from isolating changes. When a body exceeds ~10 nodes, every property change re-evaluates the entire body instead of just the affected subtree.
5. Navigation & Coordination (nav)
Impact: HIGH Description: Without a coordinator pattern, navigation logic scatters across views creating tight coupling, untestable flows, and impossible deep linking. Type-safe NavigationStack routing via coordinators is mandatory.
6. Layer Architecture (layer)
Impact: HIGH Description: Modular MVVM-C boundaries (App/Feature/Domain/Data) with strict dependency rules ensure the app remains testable, maintainable, and modular as it scales.
7. Architecture Patterns (arch)
Impact: HIGH Description: Restructuring inline state into @Observable ViewModels, extracting protocol dependencies through proper layers, and feature module extraction create testable, maintainable codebases.
8. Dependency Injection (di)
Impact: MEDIUM-HIGH Description: Hard-coded dependencies prevent unit testing, break module boundaries, and make feature isolation impossible. Environment-based injection with protocol abstractions is the SwiftUI-native DI pattern.
9. Type Safety & Protocols (type)
Impact: MEDIUM-HIGH Description: Tagged identifiers, Result types, phantom types, and force-unwrap elimination catch bugs at compile time instead of runtime.
10. List & Collection Performance (list)
Impact: MEDIUM Description: Variable view counts per element, inline filtering, and eager stacks force SwiftUI to instantiate all list items, destroying scroll performance on large datasets.
11. Async & Data Flow (data)
Impact: MEDIUM Description: Synchronous data loading blocks app launch, missing .task usage leaks Tasks, and boolean loading flags create impossible state combinations.
12. Swift Language Fundamentals (swift)
Impact: MEDIUM Description: Core Swift patterns—let vs var, structs vs classes, naming conventions, optionals, closures—form the foundation for all Swift/SwiftUI development.
Migrate Alert to confirmationDialog API
The legacy Alert struct was deprecated in iOS 15. Its boolean-based presentation forces manual state juggling and cannot bind contextual data to the alert. The modern .alert(title:isPresented:presenting:actions:message:) and .confirmationDialog modifiers accept an optional data parameter, so the alert only fires when data is non-nil and passes that data directly into the action closures.
Incorrect (deprecated Alert struct with boolean state juggling):
struct FileListView: View {
@State private var showDeleteAlert = false
@State private var fileToDelete: FileItem?
var body: some View {
List(files) { file in
FileRow(file: file)
.swipeActions {
Button("Delete") {
fileToDelete = file
showDeleteAlert = true
}
}
}
.alert(isPresented: $showDeleteAlert) {
Alert(
title: Text("Delete File"),
message: Text("Delete \(fileToDelete?.name ?? "")?"),
primaryButton: .destructive(Text("Delete")) {
guard let file = fileToDelete else { return }
deleteFile(file)
},
secondaryButton: .cancel()
)
}
}
}Correct (data-driven presentation, no boolean state):
struct FileListView: View {
@State private var fileToDelete: FileItem?
var body: some View {
List(files) { file in
FileRow(file: file)
.swipeActions {
Button("Delete") {
fileToDelete = file
}
}
}
.alert(
"Delete File",
isPresented: Binding(
get: { fileToDelete != nil },
set: { if !$0 { fileToDelete = nil } }
),
presenting: fileToDelete
) { file in
Button("Delete", role: .destructive) {
deleteFile(file)
}
} message: { file in
Text("Delete \(file.name)?")
}
}
}For destructive multi-option actions, use .confirmationDialog instead:
.confirmationDialog(
"File Options",
isPresented: $showOptions,
presenting: selectedFile
) { file in
Button("Move to Trash", role: .destructive) {
trashFile(file)
}
Button("Archive") {
archiveFile(file)
}
} message: { file in
Text("Choose an action for \(file.name)")
}Reference: alert(_:isPresented:presenting:actions:message:)-8584l)
Replace @EnvironmentObject with @Environment
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
@EnvironmentObject provides no compile-time guarantee that the object was injected. If a parent view forgets to call .environmentObject(), the app crashes at runtime with a vague error. With @Observable classes, you can inject via .environment() and access via @Environment, which surfaces missing dependencies as compile-time errors. This eliminates an entire category of runtime crashes in production.
Incorrect (runtime crash if injection is missing):
class UserSession: ObservableObject {
@Published var currentUser: User?
@Published var isAuthenticated: Bool = false
}
struct ProfileView: View {
@EnvironmentObject var session: UserSession
// Crashes at runtime if .environmentObject(session) is missing
var body: some View {
if let user = session.currentUser {
Text(user.displayName)
}
}
}
struct AppView: View {
@StateObject private var session = UserSession()
var body: some View {
ProfileView()
.environmentObject(session)
}
}Correct (compile-time safety with @Environment):
@Observable
class UserSession {
var currentUser: User?
var isAuthenticated: Bool = false
}
struct ProfileView: View {
@Environment(UserSession.self) var session
// Compiler verifies the type is registered in the environment
var body: some View {
if let user = session.currentUser {
Text(user.displayName)
}
}
}
struct AppView: View {
@State private var session = UserSession()
var body: some View {
ProfileView()
.environment(session)
}
}Reference: Migrating from the Observable Object protocol to the Observable macro
Replace id: \.self with Identifiable Conformance
Using id: \.self or id: \.name in ForEach relies on hash-based identity. When two items share the same value, or when a value changes in place, SwiftUI cannot distinguish them and produces incorrect diffs -- rows jump, animations glitch, and state binds to the wrong element. Conforming your model to Identifiable with a stable UUID gives every item a permanent identity that survives mutations and duplicates.
Incorrect (hash-based identity breaks on duplicates and mutations):
struct GroceryListView: View {
@State private var items = ["Milk", "Eggs", "Milk", "Bread"]
var body: some View {
List {
// "Milk" appears twice -- SwiftUI sees identical hashes
ForEach(items, id: \.self) { item in
Text(item)
}
.onDelete { offsets in
// Deleting one "Milk" may remove the wrong row
items.remove(atOffsets: offsets)
}
}
}
}Correct (stable UUID identity survives mutations and duplicates):
struct GroceryItem: Identifiable {
let id = UUID()
var name: String
}
struct GroceryListView: View {
@State private var items = [
GroceryItem(name: "Milk"),
GroceryItem(name: "Eggs"),
GroceryItem(name: "Milk"),
GroceryItem(name: "Bread"),
]
var body: some View {
List {
// Each item has a unique id -- duplicates diff correctly
ForEach(items) { item in
Text(item.name)
}
.onDelete { offsets in
items.remove(atOffsets: offsets)
}
}
}
}Why `\.self` is unreliable:
- Duplicate values produce identical hashes, so SwiftUI cannot tell them apart
- Renaming an item in place changes its hash, which SwiftUI interprets as a delete + insert instead of an update, breaking animations
\.selfon reference types usesObjectIdentifier, which is stable but still prevents correct animated reordering
Reference: ForEach - Apple Developer Documentation
Replace NavigationView with NavigationStack
NavigationView was deprecated in iOS 16. It couples destination views directly to links, making programmatic navigation, deep linking, and state restoration impossible. NavigationStack decouples navigation targets from links using value-based routing and NavigationPath, enabling full programmatic control over the navigation stack.
Incorrect (deprecated NavigationView with coupled destinations):
struct RecipeListView: View {
@State private var recipes: [Recipe] = []
var body: some View {
NavigationView {
List(recipes) { recipe in
NavigationLink(destination: RecipeDetailView(recipe: recipe)) {
RecipeRow(recipe: recipe)
}
}
.navigationTitle("Recipes")
}
// No way to programmatically push/pop views
// No support for deep linking or state restoration
}
}Correct (NavigationStack with value-based routing):
struct RecipeListView: View {
@State private var recipes: [Recipe] = []
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
List(recipes) { recipe in
NavigationLink(value: recipe) {
RecipeRow(recipe: recipe)
}
}
.navigationTitle("Recipes")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(recipe: recipe)
}
}
// Programmatic navigation: navigationPath.append(recipe)
// Pop to root: navigationPath.removeLast(navigationPath.count)
}
}Reference: Migrating to new navigation types
Migrate ObservableObject to @Observable Macro
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
ObservableObject uses push-based notification: any @Published change triggers re-renders in every observing view, even those that don't read the changed property. The @Observable macro (iOS 26 / Swift 6.2) uses pull-based tracking, where SwiftUI observes only the specific properties each view accesses. Every ViewModel MUST be an @Observable class held via @State in its owning view.
Incorrect (push-based notification re-renders all observers):
class ProfileViewModel: ObservableObject {
@Published var name: String = ""
@Published var bio: String = ""
@Published var isLoading: Bool = false
@Published var avatarURL: URL?
// Changing isLoading re-renders views that only read name
}
struct ProfileView: View {
@StateObject private var viewModel = ProfileViewModel()
var body: some View {
VStack {
NameHeader(name: viewModel.name)
BioSection(bio: viewModel.bio)
if viewModel.isLoading { ProgressView() }
}
}
}Correct (@Observable ViewModel — property-level tracking, @State ownership):
@Observable
class ProfileViewModel {
var name: String = ""
var bio: String = ""
var isLoading: Bool = false
var avatarURL: URL?
private let fetchProfileUseCase: FetchProfileUseCase
init(fetchProfileUseCase: FetchProfileUseCase) {
self.fetchProfileUseCase = fetchProfileUseCase
}
func loadProfile(userId: String) async {
isLoading = true
defer { isLoading = false }
if let profile = try? await fetchProfileUseCase.execute(userId: userId) {
name = profile.name
bio = profile.bio
avatarURL = profile.avatarURL
}
}
}
struct ProfileView: View {
@State var viewModel: ProfileViewModel
// @State owns the @Observable — survives view rebuilds
// Only views reading isLoading re-render when loading state changes
var body: some View {
VStack {
NameHeader(name: viewModel.name)
BioSection(bio: viewModel.bio)
LoadingOverlay(isLoading: viewModel.isLoading)
}
.task { await viewModel.loadProfile(userId: "current") }
}
}Key migration steps:
ObservableObject→@Observablemacro@Published var→ plainvar@StateObject→@State(ownership)@ObservedObject→ plain property (injected)@EnvironmentObject→@Environment
Reference: Migrating from the Observable Object protocol to the Observable macro
Migrate to New onChange Signature
The original onChange(of:perform:) closure receives only the new value and was deprecated in iOS 17. The new onChange(of:initial:_:) provides both old and new values, enabling diff-based logic without manual state tracking. Setting initial: true fires the closure on view appearance, consolidating separate onAppear + onChange handlers into a single modifier.
Incorrect (deprecated single-value closure):
struct SearchView: View {
@State private var query: String = ""
@State private var selectedCategory: Category = .all
var body: some View {
VStack {
TextField("Search", text: $query)
CategoryPicker(selection: $selectedCategory)
}
.onAppear {
performSearch(query: query, category: selectedCategory)
}
.onChange(of: query) { newValue in
performSearch(query: newValue, category: selectedCategory)
}
.onChange(of: selectedCategory) { newValue in
performSearch(query: query, category: newValue)
}
}
}Correct (new signature with old/new values and initial firing):
struct SearchView: View {
@State private var query: String = ""
@State private var selectedCategory: Category = .all
var body: some View {
VStack {
TextField("Search", text: $query)
CategoryPicker(selection: $selectedCategory)
}
.onChange(of: query, initial: true) { oldValue, newValue in
performSearch(query: newValue, category: selectedCategory)
}
.onChange(of: selectedCategory, initial: true) { oldValue, newValue in
performSearch(query: query, category: newValue)
}
// Eliminates the double-trigger risk from separate onAppear + onChange
}
}Reference: onChange(of:perform:))
Replace navigationBarItems with toolbar Modifier
.navigationBarItems(leading:trailing:) was deprecated in iOS 14 and is iOS-only. The .toolbar modifier works across all Apple platforms (iOS, macOS, watchOS, visionOS) and uses semantic placements that adapt automatically to each platform's conventions.
Incorrect (deprecated navigationBarItems, iOS-only):
struct TaskListView: View {
var body: some View {
NavigationStack {
List(tasks) { task in
TaskRow(task: task)
}
.navigationTitle("Tasks")
.navigationBarItems(
leading: Button("Edit") { startEditing() },
trailing: HStack {
Button(action: filter) {
Image(systemName: "line.3.horizontal.decrease")
}
Button(action: addTask) {
Image(systemName: "plus")
}
}
)
}
}
}Correct (toolbar with semantic placements, cross-platform):
struct TaskListView: View {
var body: some View {
NavigationStack {
List(tasks) { task in
TaskRow(task: task)
}
.navigationTitle("Tasks")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Edit") { startEditing() }
}
ToolbarItemGroup(placement: .primaryAction) {
Button(action: filter) {
Image(systemName: "line.3.horizontal.decrease")
}
Button(action: addTask) {
Image(systemName: "plus")
}
}
}
}
}
}Use ToolbarItemGroup to group multiple items under one placement instead of repeating ToolbarItem for each:
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
Button("Archive") { archiveTasks() }
Spacer()
Text("\(tasks.count) tasks")
.font(.caption)
Spacer()
Button("Compose") { composeDraft() }
}
}Reference: toolbar(content:) - Apple Developer Documentation-5w0tj)
Use Environment Keys for Service Injection
Singletons like DatabaseManager.shared create hidden coupling -- every view that uses one silently depends on global mutable state that cannot be swapped for testing or previews. SwiftUI's EnvironmentKey mechanism makes the dependency explicit and scoped to the view hierarchy: you can inject a real service at the app root and a mock at the preview root without changing the view code. This also prevents the "singleton knows too much" problem where unrelated views share and mutate the same global instance.
Incorrect (singleton creates hidden coupling, untestable):
class DatabaseManager {
static let shared = DatabaseManager()
private init() {}
func fetchNotes() async -> [Note] {
// Real database query
}
}
struct NoteListView: View {
@State private var notes: [Note] = []
var body: some View {
List(notes) { note in
Text(note.title)
}
.task {
notes = await DatabaseManager.shared.fetchNotes()
}
}
}Correct (environment key makes dependency explicit and swappable):
protocol NoteStore {
func fetchNotes() async -> [Note]
}
class DatabaseManager: NoteStore {
func fetchNotes() async -> [Note] {
// Real database query
}
}
struct MockNoteStore: NoteStore {
func fetchNotes() async -> [Note] {
[Note(id: "1", title: "Sample Note")]
}
}
private struct NoteStoreKey: EnvironmentKey {
static let defaultValue: NoteStore = DatabaseManager()
}
extension EnvironmentValues {
var noteStore: NoteStore {
get { self[NoteStoreKey.self] }
set { self[NoteStoreKey.self] = newValue }
}
}
struct NoteListView: View {
@Environment(\.noteStore) private var store
@State private var notes: [Note] = []
var body: some View {
List(notes) { note in
Text(note.title)
}
.task {
notes = await store.fetchNotes()
}
}
}
#Preview {
NoteListView()
.environment(\.noteStore, MockNoteStore())
}Reference: EnvironmentKey
Extract Features into Independent Modules
A single app target means every source file change recompiles the entire project. As the codebase grows, incremental build times balloon because Xcode cannot parallelize compilation within one target. Extracting features into local Swift packages enables parallel compilation across modules and enforces clear API boundaries -- a feature module cannot accidentally reach into another feature's internals unless you explicitly declare the dependency. This also makes features independently testable and reusable.
Incorrect (monolithic target where all features share one compilation unit):
// MyApp/
// Sources/
// App.swift
// Auth/LoginView.swift
// Auth/AuthService.swift
// Profile/ProfileView.swift
// Profile/ProfileService.swift
// Settings/SettingsView.swift
// Settings/ThemeManager.swift
// App.swift -- everything imports everything
struct MyApp: App {
var body: some Scene {
WindowGroup {
TabView {
LoginView() // directly uses AuthService
ProfileView() // directly uses ProfileService
SettingsView() // directly uses ThemeManager
}
}
}
}Correct (features extracted into local Swift packages with explicit dependencies):
// Packages/AuthFeature/Package.swift
let package = Package(
name: "AuthFeature",
platforms: [.iOS(.v17)],
products: [
.library(name: "AuthFeature", targets: ["AuthFeature"])
],
targets: [
.target(name: "AuthFeature"),
.testTarget(name: "AuthFeatureTests", dependencies: ["AuthFeature"])
]
)
// App.swift -- imports only public interfaces
import AuthFeature
import ProfileFeature
import SettingsFeature
struct MyApp: App {
var body: some Scene {
WindowGroup {
TabView {
LoginView()
ProfileView()
SettingsView()
}
}
}
}Reference: Organizing your code with local packages
Extract Business Logic into Domain Models and ViewModels
Business logic embedded in the view body — validation, formatting, network calls, data transformation — cannot be unit tested without launching the UI. Extract logic into an @Observable ViewModel that delegates to repository protocols and Domain models. The view becomes a thin rendering layer, the ViewModel exposes display-ready state, and domain logic remains reusable and testable with plain XCTest.
Incorrect (business logic inline in view body, untestable):
struct OrderSummaryView: View {
let items: [OrderItem]
@State private var promoCode: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(items) { item in
HStack {
Text(item.name)
Spacer()
Text("$\(String(format: "%.2f", item.price * Double(item.quantity)))")
}
}
let subtotal = items.reduce(0.0) { $0 + $1.price * Double($1.quantity) }
let discount = promoCode == "SAVE20" ? subtotal * 0.20 : 0
let tax = (subtotal - discount) * 0.08875
let total = subtotal - discount + tax
Divider()
TextField("Promo code", text: $promoCode)
Text("Total: $\(String(format: "%.2f", total))")
.font(.headline)
}
}
}Correct (ViewModel + repository protocol — testable, clean layer separation):
// Domain layer — pure Swift, zero framework imports
protocol CalculateOrderTotalUseCase {
func execute(items: [OrderItem], promoCode: String) -> OrderTotal
}
struct OrderTotal: Equatable {
let subtotal: Decimal
let discount: Decimal
let tax: Decimal
let total: Decimal
}
final class CalculateOrderTotalUseCaseImpl: CalculateOrderTotalUseCase {
func execute(items: [OrderItem], promoCode: String) -> OrderTotal {
let subtotal = items.reduce(Decimal.zero) { $0 + $1.price * Decimal($1.quantity) }
let discount = promoCode == "SAVE20" ? subtotal * Decimal(0.20) : Decimal.zero
let tax = (subtotal - discount) * Decimal(string: "0.08875")!
let total = subtotal - discount + tax
return OrderTotal(subtotal: subtotal, discount: discount, tax: tax, total: total)
}
}
// Presentation layer — @Observable ViewModel
@Observable
class OrderSummaryViewModel {
var promoCode: String = ""
private let items: [OrderItem]
private let calculateTotal: CalculateOrderTotalUseCase
init(items: [OrderItem], calculateTotal: CalculateOrderTotalUseCase) {
self.items = items
self.calculateTotal = calculateTotal
}
var orderTotal: OrderTotal {
calculateTotal.execute(items: items, promoCode: promoCode)
}
}
// View — thin rendering layer
struct OrderSummaryView: View {
@State var viewModel: OrderSummaryViewModel
var body: some View {
VStack(alignment: .leading, spacing: 12) {
TextField("Promo code", text: $viewModel.promoCode)
Text("Total: \(viewModel.orderTotal.total, format: .currency(code: "USD"))")
.font(.headline)
}
}
}Reference: Clean Architecture for SwiftUI
Extract Protocol Dependencies through ViewModel Layer
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Views that call concrete services or protocol dependencies directly violate modular layer boundaries — the view layer reaches into the data layer. Extract protocol interfaces and route them through an @Observable ViewModel. The ViewModel calls Repository protocols directly, and the view only reads display-ready state. This makes views and ViewModels independently testable.
Incorrect (view directly uses a service protocol — breaks layer boundary):
protocol ArticleFetching {
func fetchArticles() async throws -> [Article]
}
struct ArticleListView: View {
let fetcher: ArticleFetching
@State private var articles: [Article] = []
@State private var isLoading = false
// View directly accesses data layer — untestable without mocking at view level
// Business logic (loading state, error handling) lives in the view
var body: some View {
List(articles) { article in
Text(article.title)
}
.task {
isLoading = true
articles = (try? await fetcher.fetchArticles()) ?? []
isLoading = false
}
}
}Correct (protocol routed through ViewModel — proper layer separation):
// Domain layer — protocol and use case
protocol ArticleRepository: Sendable {
func fetchArticles() async throws -> [Article]
}
protocol FetchArticlesUseCase {
func execute() async throws -> [Article]
}
final class FetchArticlesUseCaseImpl: FetchArticlesUseCase {
private let repository: ArticleRepository
init(repository: ArticleRepository) {
self.repository = repository
}
func execute() async throws -> [Article] {
try await repository.fetchArticles()
}
}
// Presentation layer — ViewModel owns the logic
@Observable
class ArticleListViewModel {
var articles: [Article] = []
var isLoading: Bool = false
var errorMessage: String?
private let fetchArticles: FetchArticlesUseCase
init(fetchArticles: FetchArticlesUseCase) {
self.fetchArticles = fetchArticles
}
func load() async {
isLoading = true
defer { isLoading = false }
do {
articles = try await fetchArticles.execute()
} catch {
errorMessage = error.localizedDescription
}
}
}
// View — thin rendering layer, no data access
struct ArticleListView: View {
@State var viewModel: ArticleListViewModel
var body: some View {
List(viewModel.articles) { article in
Text(article.title)
}
.overlay {
if viewModel.isLoading { ProgressView() }
}
.task { await viewModel.load() }
}
}Reference: Clean Architecture for SwiftUI
Restructure Inline State into @Observable ViewModel
Views that mix @State declarations with business logic (validation, formatting, network calls) become untestable and hard to reason about. Extract state and logic into an @Observable class held via @State in the owning view. The ViewModel exposes display-ready properties and the view becomes a thin rendering layer. This enables unit testing of all logic without launching the UI.
Incorrect (business logic mixed into view, untestable):
struct OrderSummaryView: View {
@State private var items: [OrderItem] = []
@State private var promoCode: String = ""
@State private var isLoading: Bool = false
@State private var errorMessage: String?
private var subtotal: Decimal {
items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
}
private var discount: Decimal {
promoCode == "SAVE20" ? subtotal * Decimal(0.20) : 0
}
private var total: Decimal {
subtotal - discount
}
var body: some View {
VStack {
if isLoading { ProgressView() }
List(items) { item in
Text(item.name)
}
TextField("Promo", text: $promoCode)
Text("Total: \(total, format: .currency(code: "USD"))")
}
.task {
isLoading = true
defer { isLoading = false }
do {
items = try await OrderService.shared.fetchItems()
} catch {
errorMessage = error.localizedDescription
}
}
}
}Correct (@Observable ViewModel — testable, property-level tracking):
@Observable
class OrderSummaryViewModel {
var items: [OrderItem] = []
var promoCode: String = ""
var isLoading: Bool = false
var errorMessage: String?
private let fetchOrdersUseCase: FetchOrdersUseCase
init(fetchOrdersUseCase: FetchOrdersUseCase) {
self.fetchOrdersUseCase = fetchOrdersUseCase
}
var subtotal: Decimal {
items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
}
var discount: Decimal {
promoCode == "SAVE20" ? subtotal * Decimal(0.20) : 0
}
var total: Decimal {
subtotal - discount
}
func loadItems() async {
isLoading = true
defer { isLoading = false }
do {
items = try await fetchOrdersUseCase.execute()
} catch {
errorMessage = error.localizedDescription
}
}
}
struct OrderSummaryView: View {
@State var viewModel: OrderSummaryViewModel
var body: some View {
VStack {
if viewModel.isLoading { ProgressView() }
List(viewModel.items) { item in
Text(item.name)
}
TextField("Promo", text: $viewModel.promoCode)
Text("Total: \(viewModel.total, format: .currency(code: "USD"))")
}
.task { await viewModel.loadItems() }
}
}Reference: WWDC23 — Discover Observation in SwiftUI
Use .task Automatic Cancellation — Never Manage Tasks Manually
Storing Task handles in properties and manually cancelling them in onDisappear is error-prone — it's easy to forget cancellation, create race conditions, or cancel too early. The .task modifier provides automatic structured cancellation: the task is cancelled when the view disappears, and restarted with .task(id:) when a dependency changes.
Incorrect (manual Task management — leak-prone, race-prone):
@Observable
class SearchViewModel {
var query: String = ""
var results: [SearchResult] = []
private var searchTask: Task<Void, Never>?
// Manual task tracking — easy to forget cancellation
func search() {
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
results = (try? await searchUseCase.execute(query: query)) ?? []
}
}
}
struct SearchView: View {
@State var viewModel: SearchViewModel
var body: some View {
List(viewModel.results) { result in
Text(result.title)
}
.searchable(text: $viewModel.query)
.onChange(of: viewModel.query) {
viewModel.search()
}
.onDisappear {
// Easy to forget this — leaked task continues
viewModel.searchTask?.cancel()
}
}
}Correct (.task with id — automatic cancellation and restart):
@Observable
class SearchViewModel {
var query: String = ""
var results: [SearchResult] = []
private let searchUseCase: SearchUseCase
init(searchUseCase: SearchUseCase) {
self.searchUseCase = searchUseCase
}
func search() async {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
results = (try? await searchUseCase.execute(query: query)) ?? []
}
}
struct SearchView: View {
@State var viewModel: SearchViewModel
var body: some View {
List(viewModel.results) { result in
Text(result.title)
}
.searchable(text: $viewModel.query)
.task(id: viewModel.query) {
// Automatically cancelled when query changes or view disappears
// No manual Task tracking needed
await viewModel.search()
}
}
}Reference: Managing model data in your app
Model Loading States as Enum Instead of Boolean Flags
Multiple boolean flags (isLoading, hasError, isEmpty) create impossible states — like being both loading and errored simultaneously. Model the loading lifecycle as an enum with distinct cases. The view switches on the enum, guaranteeing every state is handled and no invalid combinations exist.
Incorrect (boolean flags create impossible state combinations):
@Observable
class FeedViewModel {
var posts: [Post] = []
var isLoading: Bool = false
var error: Error?
// Possible invalid state: isLoading = true AND error != nil
func load() async {
isLoading = true
error = nil
do {
posts = try await fetchPosts.execute()
} catch {
self.error = error
}
isLoading = false
}
}
struct FeedView: View {
@State var viewModel: FeedViewModel
var body: some View {
VStack {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.error {
Text(error.localizedDescription)
} else if viewModel.posts.isEmpty {
Text("No posts")
} else {
PostList(posts: viewModel.posts)
}
}
}
}Correct (enum guarantees exactly one state at a time):
enum Loadable<Value> {
case idle
case loading
case loaded(Value)
case failed(Error)
}
@Observable
class FeedViewModel {
var state: Loadable<[Post]> = .idle
private let fetchPosts: FetchPostsUseCase
init(fetchPosts: FetchPostsUseCase) {
self.fetchPosts = fetchPosts
}
func load() async {
state = .loading
do {
let posts = try await fetchPosts.execute()
state = .loaded(posts)
} catch {
state = .failed(error)
}
}
}
struct FeedView: View {
@State var viewModel: FeedViewModel
var body: some View {
Group {
switch viewModel.state {
case .idle:
Color.clear
case .loading:
ProgressView()
case .loaded(let posts):
if posts.isEmpty {
ContentUnavailableView("No Posts", systemImage: "doc")
} else {
PostList(posts: posts)
}
case .failed(let error):
ContentUnavailableView("Error", systemImage: "exclamationmark.triangle",
description: Text(error.localizedDescription))
}
}
.task { await viewModel.load() }
}
}Reference: WWDC23 — Discover Observation in SwiftUI
Replace onAppear Async Work with .task Modifier
.onAppear with Task { } creates a detached task that continues running even after the view disappears — leaked network calls, database writes, and state mutations on deallocated ViewModels. The .task modifier creates a structured task that SwiftUI automatically cancels when the view disappears.
Incorrect (onAppear + Task — leaked async work on disappear):
struct ProfileView: View {
@State var viewModel: ProfileViewModel
var body: some View {
VStack {
Text(viewModel.name)
}
.onAppear {
Task {
// This task continues even after ProfileView disappears
// Can mutate viewModel after the view is gone
await viewModel.loadProfile()
}
}
}
}Correct (.task — automatic cancellation on disappear):
struct ProfileView: View {
@State var viewModel: ProfileViewModel
var body: some View {
VStack {
Text(viewModel.name)
}
.task {
// Automatically cancelled when ProfileView disappears
// Cooperative cancellation — async calls check Task.isCancelled
await viewModel.loadProfile()
}
}
}Reacting to value changes:
struct SearchResultsView: View {
@State var viewModel: SearchViewModel
var body: some View {
List(viewModel.results) { result in
Text(result.title)
}
.task(id: viewModel.query) {
// Cancelled and restarted when query changes
await viewModel.search()
}
}
}Reference: Managing model data in your app
Compose Dependency Container at App Root
Dependencies scattered across view initializers create implicit coupling and make it impossible to swap implementations for testing. Compose all dependencies at the app root using @Environment injection. Each feature coordinator or view receives its dependencies from the environment, and the app root is the single place where real vs mock implementations are chosen.
Incorrect (dependencies created inline — scattered, no central wiring):
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
// Dependencies created ad-hoc — no central control
OrderListView(
viewModel: OrderListViewModel(
fetchOrders: FetchOrdersUseCaseImpl(
repository: APIOrderRepository(session: .shared)
)
)
)
}
}
}Correct (dependency container composed at app root, injected via Environment):
@Observable
final class AppDependencies {
let orderRepository: OrderRepository
let fetchOrders: FetchOrdersUseCase
let cancelOrder: CancelOrderUseCase
init(orderRepository: OrderRepository) {
self.orderRepository = orderRepository
self.fetchOrders = FetchOrdersUseCaseImpl(repository: orderRepository)
self.cancelOrder = CancelOrderUseCaseImpl(repository: orderRepository)
}
static let live = AppDependencies(
orderRepository: APIOrderRepository(session: .shared)
)
static let mock = AppDependencies(
orderRepository: MockOrderRepository()
)
}
@main
struct MyApp: App {
@State private var dependencies = AppDependencies.live
var body: some Scene {
WindowGroup {
OrderFlowView()
.environment(dependencies)
}
}
}
// Feature views read dependencies from environment
struct OrderFlowView: View {
@Environment(AppDependencies.self) private var deps
@State private var coordinator = OrderCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
OrderListView(
viewModel: OrderListViewModel(fetchOrders: deps.fetchOrders)
)
}
.environment(coordinator)
}
}
// Previews use mock container
#Preview {
OrderFlowView()
.environment(AppDependencies.mock)
}Reference: EnvironmentKey
Add Mock Implementation for Every Protocol Dependency
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Protocol dependencies without mock implementations cannot be tested in isolation. For every repository or use case protocol, create a corresponding mock that captures method calls and returns stubbed data. This enables testing ViewModel logic, use case composition, and error handling without real network calls, databases, or simulators.
Incorrect (no mocks — ViewModel untestable without real API):
protocol OrderRepository: Sendable {
func fetchAll() async throws -> [Order]
func cancel(orderId: String) async throws
}
// Only implementation is the real API client
final class APIOrderRepository: OrderRepository {
func fetchAll() async throws -> [Order] {
// Real network call
}
func cancel(orderId: String) async throws {
// Real network call
}
}
// Cannot test OrderViewModel without hitting the APICorrect (mock captures calls, stubs return values, verifies interactions):
protocol OrderRepository: Sendable {
func fetchAll() async throws -> [Order]
func cancel(orderId: String) async throws
}
final class MockOrderRepository: OrderRepository {
var stubbedOrders: [Order] = []
var stubbedError: Error?
var cancelledOrderIds: [String] = []
func fetchAll() async throws -> [Order] {
if let error = stubbedError { throw error }
return stubbedOrders
}
func cancel(orderId: String) async throws {
if let error = stubbedError { throw error }
cancelledOrderIds.append(orderId)
}
}
// Test ViewModel in complete isolation
@Test
func loadOrders_displaysOrders() async {
let mock = MockOrderRepository()
mock.stubbedOrders = [
Order(id: "1", title: "Widget", status: .pending)
]
let useCase = FetchOrdersUseCaseImpl(repository: mock)
let viewModel = OrderListViewModel(fetchOrders: useCase)
await viewModel.load()
#expect(viewModel.orders.count == 1)
#expect(viewModel.orders.first?.title == "Widget")
}
@Test
func loadOrders_handlesError() async {
let mock = MockOrderRepository()
mock.stubbedError = URLError(.notConnectedToInternet)
let useCase = FetchOrdersUseCaseImpl(repository: mock)
let viewModel = OrderListViewModel(fetchOrders: useCase)
await viewModel.load()
#expect(viewModel.orders.isEmpty)
#expect(viewModel.errorMessage != nil)
}Reference: Testing in Xcode
Use @EquatableIgnored for Closure and Handler Properties
Closures cannot conform to Equatable — their reference identity changes on every parent render. Without @EquatableIgnored, the @Equatable macro cannot generate valid conformance, and the build fails. Mark all closure/handler properties with @EquatableIgnored so they are excluded from the equality check. The view's body only re-evaluates when its Equatable properties actually change.
Incorrect (closure property breaks Equatable — view is non-diffable):
struct ActionButton: View {
let title: String
let icon: String
let onTap: () -> Void
// No Equatable conformance — body re-evaluates on every parent update
// Even if title and icon haven't changed
var body: some View {
Button {
onTap()
} label: {
Label(title, systemImage: icon)
}
.buttonStyle(.borderedProminent)
}
}Correct (@EquatableIgnored on closure — view diffs on title and icon only):
@Equatable
struct ActionButton: View {
let title: String
let icon: String
@EquatableIgnored
let onTap: () -> Void
// title and icon compared for equality
// onTap excluded — body only re-evaluates when title or icon change
var body: some View {
Button {
onTap()
} label: {
Label(title, systemImage: icon)
}
.buttonStyle(.borderedProminent)
}
}Multiple handlers:
@Equatable
struct SwipeableRow: View {
let item: ListItem
@EquatableIgnored let onDelete: () -> Void
@EquatableIgnored let onArchive: () -> Void
@EquatableIgnored let onFlag: () -> Void
// Only item is compared — all handlers are excluded
var body: some View {
Text(item.title)
.swipeActions(edge: .trailing) {
Button("Delete", role: .destructive, action: onDelete)
Button("Archive", action: onArchive)
}
.swipeActions(edge: .leading) {
Button("Flag", action: onFlag)
}
}
}Reference: Airbnb Engineering — Understanding and Improving SwiftUI Performance
Add @Equatable Macro to Every SwiftUI View
SwiftUI's reflection-based diffing fails silently when views contain non-Equatable properties. If ANY stored property isn't diffable, the ENTIRE view becomes non-diffable and its body re-evaluates on every parent update. Add the @Equatable macro to every view — it generates Equatable conformance for all stored properties and fails the build if a non-Equatable property is added without @EquatableIgnored.
Incorrect (no @Equatable — SwiftUI can't diff, body always re-evaluates):
struct ProductCard: View {
let product: Product // struct — diffable
let onAddToCart: () -> Void // closure — NOT diffable
// SwiftUI's reflection can't diff this — body runs on EVERY parent update
var body: some View {
VStack {
Text(product.name)
Text(product.price, format: .currency(code: "USD"))
Button("Add to Cart", action: onAddToCart)
}
}
}Correct (@Equatable — guaranteed diffable, body only re-evaluates when data changes):
@Equatable
struct ProductCard: View {
let product: Product // included in Equatable comparison
@EquatableIgnored
let onAddToCart: () -> Void // closure excluded from comparison
var body: some View {
VStack {
Text(product.name)
Text(product.price, format: .currency(code: "USD"))
Button("Add to Cart", action: onAddToCart)
}
}
}
// Build fails if a non-Equatable property is added without @EquatableIgnored
// @State and @Environment wrappers are automatically excludedAlternative (built-in SwiftUI, no third-party dependency):
struct ProductCard: View, Equatable {
let product: Product
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.product == rhs.product
}
var body: some View {
VStack {
Text(product.name)
Text(product.price, format: .currency(code: "USD"))
}
}
}Prerequisite: The @Equatable macro requires the `ordo-one/equatable` SPM package. Add it via Package.swift or Xcode's package manager.
Reference: Airbnb Engineering — Understanding and Improving SwiftUI Performance
Use Stable O(1) Identifiers in ForEach
SwiftUI uses view identity to track which items were added, removed, or moved in a list. If identifiers are unstable (array index, computed hash, or \.self on non-unique values), SwiftUI cannot correlate items across updates and tears down/rebuilds the entire list. Use stable, unique, O(1) identifiers — typically a UUID or database primary key.
Incorrect (array index as identity — full rebuild on any mutation):
struct TaskListView: View {
@State var viewModel: TaskListViewModel
var body: some View {
List {
ForEach(Array(viewModel.tasks.enumerated()), id: \.offset) { index, task in
// Index-based identity: inserting at position 0
// shifts ALL indices, causing full list rebuild
TaskRow(task: task)
}
}
}
}Also incorrect (\.self on non-unique strings — collisions cause missing rows):
struct TagListView: View {
let tags: [String]
var body: some View {
ForEach(tags, id: \.self) { tag in
// Duplicate strings have same identity — SwiftUI drops duplicates
// "swift" appearing twice renders only once
TagChip(text: tag)
}
}
}Correct (stable Identifiable conformance — O(1) lookup, correct diffing):
struct TaskItem: Identifiable, Equatable {
let id: UUID
var title: String
var isCompleted: Bool
}
struct TaskListView: View {
@State var viewModel: TaskListViewModel
var body: some View {
List {
ForEach(viewModel.tasks) { task in
// UUID identity persists across mutations
// Inserting/removing items only affects changed rows
TaskRow(task: task)
}
}
}
}
struct Tag: Identifiable, Equatable {
let id: UUID
let text: String
}
struct TagListView: View {
let tags: [Tag]
var body: some View {
ForEach(tags) { tag in
// Each tag has unique identity — no collisions
TagChip(text: tag.text)
}
}
}Reference: Demystify SwiftUI — WWDC21
Use _printChanges() to Diagnose Unnecessary Re-renders
When a view re-renders more often than expected, add Self._printChanges() at the top of the body to identify which property triggered the evaluation. The output shows the view name and the specific property that changed (or @self if the view's identity changed). Use this during development to find and fix over-observation — remove it before shipping.
Incorrect (unexplained re-renders, no diagnostic output):
struct OrderStatusBadge: View {
var viewModel: OrderViewModel
// Re-renders constantly but unclear why
var body: some View {
Label(viewModel.statusLabel, systemImage: viewModel.statusIcon)
.foregroundStyle(viewModel.statusColor)
}
}Correct (add _printChanges() to diagnose, then fix root cause):
// Step 1: Add diagnostic
struct OrderStatusBadge: View {
var viewModel: OrderViewModel
var body: some View {
let _ = Self._printChanges()
// Output: "OrderStatusBadge: _viewModel changed."
// Reveals the @Observable ViewModel triggers this view
// even when statusLabel/statusIcon haven't changed
Label(viewModel.statusLabel, systemImage: viewModel.statusIcon)
.foregroundStyle(viewModel.statusColor)
}
}
// Step 2: Fix by passing only needed properties
struct OrderStatusBadge: View {
let statusLabel: String
let statusIcon: String
let statusColor: Color
var body: some View {
// No more unnecessary re-renders — only updates when these 3 values change
Label(statusLabel, systemImage: statusIcon)
.foregroundStyle(statusColor)
}
}Common _printChanges() outputs and what they mean:
"ViewName: _propertyName changed."— that specific property triggered re-render"ViewName: @self changed."— the view's identity changed (parent recreated it)"ViewName: _viewModel changed."— an @Observable property the view reads was modified
Important: Remove _printChanges() before production — it's a debug-only API that prints to the console.
Reference: Demystify SwiftUI performance - WWDC23
Extract Domain Layer with Zero Framework Imports
Business logic scattered across views and services that import SwiftUI, CoreData, or networking frameworks cannot be tested without a simulator. Extract a Domain layer containing models, repository/coordinator/error protocols with zero framework imports. import Foundation is acceptable for standard types (Date, URL, UUID, Decimal) but NEVER for networking or persistence.
Incorrect (domain logic coupled to frameworks — requires simulator to test):
import SwiftUI
import SwiftData
class FetchUserProfileUseCase {
func execute(userId: String) async throws -> UserProfile {
let url = URL(string: "https://api.example.com/users/\(userId)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(UserProfile.self, from: data)
}
}
@Model
class UserProfile {
var id: String
var name: String
@Attribute(.spotlight) var bio: String
var displayColor: Color { isPremium ? .gold : .primary }
}Correct (pure Swift domain — zero framework imports, testable anywhere):
// Domain/UseCases/FetchUserProfileUseCase.swift
// No framework imports — pure Swift
protocol FetchUserProfileUseCase {
func execute(userId: String) async throws -> UserProfile
}
final class FetchUserProfileUseCaseImpl: FetchUserProfileUseCase {
private let userRepository: UserRepository
init(userRepository: UserRepository) {
self.userRepository = userRepository
}
func execute(userId: String) async throws -> UserProfile {
let user = try await userRepository.fetchUser(id: userId)
return UserProfile(
id: user.id,
name: user.name,
bio: user.bio,
membershipTier: MembershipTier(from: user.joinDate)
)
}
}
// Domain/Models/UserProfile.swift
struct UserProfile: Equatable, Sendable {
let id: String
let name: String
let bio: String
let membershipTier: MembershipTier
}
// Domain/Repositories/UserRepository.swift
protocol UserRepository: Sendable {
func fetchUser(id: String) async throws -> User
}Dependency rule: Views -> Domain <- Data. Domain has zero framework imports.
Reference: Clean Architecture for SwiftUI
Remove Direct Repository Access from Views
Views should not read repositories directly. Route data access through @Observable ViewModels so refactors can preserve feature boundaries and swap Data implementations without touching UI code.
Incorrect (view calls repository):
struct BookmarkListView: View {
@Environment(\.bookmarkRepository) private var repository
@State private var bookmarks: [Bookmark] = []
var body: some View {
List(bookmarks) { bookmark in
Text(bookmark.title)
}
.task {
bookmarks = (try? await repository.fetchAll()) ?? []
}
}
}Correct (view reads ViewModel, ViewModel calls repository):
protocol BookmarkRepository: Sendable {
func fetchAll() async throws -> [Bookmark]
}
@Observable
final class BookmarkListViewModel {
private let repository: any BookmarkRepository
var bookmarks: [Bookmark] = []
init(repository: any BookmarkRepository) {
self.repository = repository
}
func load() async {
bookmarks = (try? await repository.fetchAll()) ?? []
}
}
struct BookmarkListView: View {
@State var viewModel: BookmarkListViewModel
var body: some View {
List(viewModel.bookmarks) { bookmark in
Text(bookmark.title)
}
.task { await viewModel.load() }
}
}Extract Repository Protocols in Domain, Implementations in Data
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
When ViewModels or views directly call network or database APIs, the domain layer becomes coupled to specific frameworks. Extract a Repository protocol in the Domain layer defining WHAT data operations are needed. Place the concrete implementation in the Data layer, where it handles HOW to fetch/store. This lets you swap between API, database, cache, or mock implementations without changing any business logic.
Incorrect (use case directly calls URLSession — coupled to networking framework):
final class FetchProductsUseCaseImpl: FetchProductsUseCase {
func execute(categoryId: String) async throws -> [Product] {
let url = URL(string: "https://api.example.com/categories/\(categoryId)/products")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Product].self, from: data)
}
}Correct (protocol in Domain, implementation in Data):
// Domain/Repositories/ProductRepository.swift
// Pure protocol — no framework imports
protocol ProductRepository: Sendable {
func fetchProducts(categoryId: String) async throws -> [Product]
func saveProduct(_ product: Product) async throws
}
// Domain/UseCases/FetchProductsUseCaseImpl.swift
final class FetchProductsUseCaseImpl: FetchProductsUseCase {
private let repository: ProductRepository
init(repository: ProductRepository) {
self.repository = repository
}
func execute(categoryId: String) async throws -> [Product] {
try await repository.fetchProducts(categoryId: categoryId)
}
}
// Data/Repositories/APIProductRepository.swift
import Foundation
final class APIProductRepository: ProductRepository {
private let session: URLSession
private let decoder = JSONDecoder()
init(session: URLSession = .shared) {
self.session = session
}
func fetchProducts(categoryId: String) async throws -> [Product] {
let url = URL(string: "https://api.example.com/categories/\(categoryId)/products")!
let (data, _) = try await session.data(from: url)
return try decoder.decode([Product].self, from: data)
}
func saveProduct(_ product: Product) async throws {
// POST to API
}
}
// Data/Repositories/MockProductRepository.swift
final class MockProductRepository: ProductRepository {
var stubbedProducts: [Product] = []
func fetchProducts(categoryId: String) async throws -> [Product] {
stubbedProducts
}
func saveProduct(_ product: Product) async throws {
stubbedProducts.append(product)
}
}Reference: Clean Architecture for SwiftUI
Remove Use-Case/Interactor Layers That Add Ceremony
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
When refactoring toward the clinic architecture, collapse single-purpose use-case wrappers into repository-backed ViewModel logic. Keep protocol boundaries at repository/coordinator/error-routing interfaces in Domain.
Incorrect (one-line use-case wrappers):
protocol FetchOrdersUseCase {
func execute() async throws -> [Order]
}
final class FetchOrdersUseCaseImpl: FetchOrdersUseCase {
private let repository: OrderRepository
init(repository: OrderRepository) {
self.repository = repository
}
func execute() async throws -> [Order] {
try await repository.fetchAll()
}
}Correct (ViewModel uses repository protocol directly):
protocol OrderRepository: Sendable {
func fetchAll() async throws -> [Order]
func cancel(orderID: String) async throws
}
@Observable
final class OrderViewModel {
private let repository: any OrderRepository
var orders: [Order] = []
init(repository: any OrderRepository) {
self.repository = repository
}
func load() async {
orders = (try? await repository.fetchAll()) ?? []
}
func cancel(_ order: Order) async {
do {
try await repository.cancel(orderID: order.id)
orders.removeAll { $0.id == order.id }
} catch {
// Route through ErrorRouting / AppError policy.
}
}
}Only keep a dedicated use-case abstraction when the same cross-feature workflow is reused in multiple modules.
Refactor ViewModels to Expose Display-Ready State Only
ViewModels that expose raw domain models force views to contain formatting, filtering, and transformation logic in their bodies. This logic re-executes on every render pass and is untestable. Refactor ViewModels to expose display-ready state — formatted strings, computed booleans, sorted arrays — so the view body is pure layout with zero data transformation.
Incorrect (ViewModel exposes raw data, view transforms it):
@Observable
class ProfileViewModel {
var user: User?
var joinDate: Date?
var followerCount: Int = 0
func load() async {
user = try? await fetchProfileUseCase.execute()
joinDate = user?.createdAt
followerCount = user?.followers.count ?? 0
}
}
struct ProfileView: View {
@State var viewModel: ProfileViewModel
var body: some View {
VStack {
// View transforms raw data — this runs on every render
Text(viewModel.user?.name ?? "Unknown")
Text("Joined \(viewModel.joinDate ?? Date(), style: .date)")
Text("\(viewModel.followerCount) followers")
Text(viewModel.followerCount > 1000 ? "Popular" : "Growing")
.foregroundStyle(viewModel.followerCount > 1000 ? .blue : .secondary)
}
}
}Correct (ViewModel exposes display-ready state, view is pure layout):
@Observable
class ProfileViewModel {
var displayName: String = ""
var joinDateLabel: String = ""
var followerLabel: String = ""
var popularityLabel: String = ""
var isPopular: Bool = false
private let fetchProfile: FetchProfileUseCase
init(fetchProfile: FetchProfileUseCase) {
self.fetchProfile = fetchProfile
}
func load() async {
guard let user = try? await fetchProfile.execute() else { return }
displayName = user.name
joinDateLabel = "Joined \(user.createdAt.formatted(date: .abbreviated, time: .omitted))"
followerLabel = "\(user.followers.count) followers"
isPopular = user.followers.count > 1000
popularityLabel = isPopular ? "Popular" : "Growing"
}
}
struct ProfileView: View {
@State var viewModel: ProfileViewModel
var body: some View {
VStack {
Text(viewModel.displayName)
Text(viewModel.joinDateLabel)
Text(viewModel.followerLabel)
Text(viewModel.popularityLabel)
.foregroundStyle(viewModel.isPopular ? .blue : .secondary)
}
.task { await viewModel.load() }
}
}Reference: Advanced iOS App Architecture (4th Ed.)
Ensure ForEach Produces Constant View Count Per Element
When ForEach produces different numbers of views per element (conditional views inside the loop), SwiftUI cannot maintain stable structural identity. Each state change may shift which elements have extra views, causing the list to tear down and rebuild rows instead of diffing them. Ensure every ForEach iteration produces the same number of child views.
Incorrect (variable view count per element — structural identity unstable):
struct NotificationList: View {
@State var viewModel: NotificationListViewModel
var body: some View {
List {
ForEach(viewModel.notifications) { notification in
VStack(alignment: .leading) {
Text(notification.title)
Text(notification.body)
// Variable view count: some items have 3 children, others have 2
if notification.hasAction {
Button(notification.actionLabel) {
viewModel.performAction(notification)
}
}
}
}
}
}
}Correct (constant view count — hide via opacity or move to subview):
struct NotificationList: View {
@State var viewModel: NotificationListViewModel
var body: some View {
List {
ForEach(viewModel.notifications) { notification in
NotificationRow(
notification: notification,
onAction: { viewModel.performAction(notification) }
)
}
}
}
}
@Equatable
struct NotificationRow: View {
let notification: AppNotification
@EquatableIgnored let onAction: () -> Void
var body: some View {
VStack(alignment: .leading) {
Text(notification.title)
Text(notification.body)
// Constant view count — button always present, hidden when no action
Button(notification.actionLabel, action: onAction)
.opacity(notification.hasAction ? 1 : 0)
.disabled(!notification.hasAction)
}
}
}Reference: Demystify SwiftUI — WWDC21
Move Filter and Sort Logic from ForEach into ViewModel
Filtering or sorting inside the view body re-executes on every render pass — even when the data hasn't changed. Move these operations into the ViewModel as computed properties or cached results. The view body receives the already-filtered array and simply iterates it.
Incorrect (filtering in view body — re-executes on every render):
struct TaskListView: View {
@State var viewModel: TaskListViewModel
var body: some View {
List {
// This filter runs on EVERY body evaluation
ForEach(viewModel.tasks.filter { $0.status == viewModel.selectedFilter }) { task in
TaskRow(task: task)
}
}
.toolbar {
Picker("Filter", selection: $viewModel.selectedFilter) {
ForEach(TaskStatus.allCases, id: \.self) { status in
Text(status.label).tag(status)
}
}
}
}
}Correct (filtering in ViewModel — computed once, view just iterates):
@Observable
class TaskListViewModel {
var tasks: [TaskItem] = []
var selectedFilter: TaskStatus = .all
var filteredTasks: [TaskItem] {
guard selectedFilter != .all else { return tasks }
return tasks.filter { $0.status == selectedFilter }
}
private let fetchTasks: FetchTasksUseCase
init(fetchTasks: FetchTasksUseCase) {
self.fetchTasks = fetchTasks
}
func load() async {
tasks = (try? await fetchTasks.execute()) ?? []
}
}
struct TaskListView: View {
@State var viewModel: TaskListViewModel
var body: some View {
List {
ForEach(viewModel.filteredTasks) { task in
TaskRow(task: task)
}
}
.toolbar {
Picker("Filter", selection: $viewModel.selectedFilter) {
ForEach(TaskStatus.allCases, id: \.self) { status in
Text(status.label).tag(status)
}
}
}
.task { await viewModel.load() }
}
}Reference: Managing model data in your app
Provide Explicit id KeyPath — Never Rely on Implicit Identity
When ForEach infers identity from Identifiable conformance, a model change (like switching from UUID to database ID) can silently break list diffing. Using id: \.self on non-unique values (like String or Int) causes collisions where duplicate values render only once. Always provide an explicit id keyPath to make the identity source visible and intentional.
Incorrect (id: \.self on non-unique values — collisions):
struct CategoryPicker: View {
let categories: [String]
var body: some View {
ForEach(categories, id: \.self) { category in
// "Electronics" appearing twice renders only once
// SwiftUI drops the "duplicate" identity
Text(category)
}
}
}Correct (explicit id keyPath on unique property):
struct Category: Identifiable, Equatable {
let id: UUID
let name: String
}
struct CategoryPicker: View {
let categories: [Category]
var body: some View {
ForEach(categories, id: \.id) { category in
// Explicit keyPath — identity source is visible and unique
Text(category.name)
}
}
}Correct (Identifiable conformance with explicit id):
struct CategoryPicker: View {
let categories: [Category]
var body: some View {
// Identifiable provides id automatically
// But the model must guarantee uniqueness
ForEach(categories) { category in
Text(category.name)
}
}
}Reference: Demystify SwiftUI — WWDC21
Replace VStack/HStack with LazyVStack/LazyHStack for Unbounded Content
VStack and HStack inside a ScrollView instantiate ALL child views immediately, even those offscreen. For unbounded or large content, this means O(N) memory allocation and body evaluations at load time. LazyVStack and LazyHStack instantiate views on demand as they scroll into the visible area.
Incorrect (VStack instantiates all 1000 items at once):
struct FeedView: View {
@State var viewModel: FeedViewModel
var body: some View {
ScrollView {
VStack(spacing: 12) {
// All 1000 PostCard views created immediately
// Massive memory spike and slow initial render
ForEach(viewModel.posts) { post in
PostCard(post: post)
}
}
}
}
}Correct (LazyVStack creates views on demand):
struct FeedView: View {
@State var viewModel: FeedViewModel
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
// Views created as they scroll into view
// O(visible) memory — typically 10-20 views at a time
ForEach(viewModel.posts) { post in
PostCard(post: post)
}
}
}
}
}When to use eager stacks: Use VStack/HStack when the content count is known and small (< 20 items), or when you need the full layout calculated upfront (e.g., for matchedGeometryEffect).
Reference: Lazy Stacks
Refactor Navigation to Coordinator Pattern
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Scattered .navigationDestination(for:) modifiers across child views cause duplicate registrations and unpredictable routing. Refactor to a coordinator pattern: an @Observable class that owns the NavigationPath and all routing decisions. Views request navigation by calling coordinator methods — they never create destinations inline. This centralizes flow logic, enables deep linking, and makes navigation testable.
Incorrect (destinations scattered across child views — no coordinator):
struct AppRootView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
CategoryListView()
}
}
}
struct CategoryListView: View {
var body: some View {
List(Category.allCases) { category in
NavigationLink(value: category) {
CategoryRow(category: category)
}
}
.navigationDestination(for: Category.self) { category in
ProductListView(category: category)
}
}
}
struct ProductListView: View {
let category: Category
var body: some View {
List(category.products) { product in
NavigationLink(value: product) {
ProductRow(product: product)
}
}
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
}
}Correct (coordinator owns NavigationStack and all routing):
enum CatalogRoute: Hashable {
case category(Category)
case product(Product)
}
@Observable
final class CatalogCoordinator {
var path = NavigationPath()
func navigate(to route: CatalogRoute) {
path.append(route)
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
func handle(url: URL) -> Bool {
guard let route = CatalogRoute(url: url) else { return false }
navigate(to: route)
return true
}
}
struct CatalogFlowView: View {
@State private var coordinator = CatalogCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
CategoryListView()
.navigationDestination(for: CatalogRoute.self) { route in
switch route {
case .category(let category):
ProductListView(category: category)
case .product(let product):
ProductDetailView(product: product)
}
}
}
.environment(coordinator)
}
}
struct CategoryListView: View {
@Environment(CatalogCoordinator.self) private var coordinator
var body: some View {
List(Category.allCases) { category in
Button { coordinator.navigate(to: .category(category)) } label: {
CategoryRow(category: category)
}
}
}
}Reference: Advanced iOS App Architecture (4th Ed.)
Use NavigationPath for Programmatic Navigation
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Managing navigation with individual @State booleans does not compose: each new screen requires another boolean, you cannot pop to the root in one call, you cannot encode a deep link as a sequence of destinations, and you cannot persist and restore the navigation stack across app launches. NavigationPath replaces all of those booleans with a single type-erased stack that supports append, removeLast, and Codable serialization for state restoration.
Incorrect (boolean flags for each navigation destination):
struct HomeView: View {
@State private var isShowingDetail = false
@State private var isShowingSettings = false
@State private var selectedItem: Item?
var body: some View {
NavigationStack {
List(items) { item in
Button(item.name) {
selectedItem = item
isShowingDetail = true
}
}
.navigationDestination(isPresented: $isShowingDetail) {
if let selectedItem {
ItemDetailView(item: selectedItem)
}
}
.navigationDestination(isPresented: $isShowingSettings) {
SettingsView()
}
// Cannot pop to root without resetting every boolean
// Cannot encode navigation state for deep links
}
}
}Correct (NavigationPath for unified programmatic control):
struct HomeView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List(items) { item in
NavigationLink(value: item) {
Text(item.name)
}
}
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
.navigationDestination(for: SettingsRoute.self) { _ in
SettingsView()
}
.toolbar {
Button("Settings") { path.append(SettingsRoute()) }
}
}
// Pop to root: path.removeLast(path.count)
// Deep link: path.append(item); path.append(SettingsRoute())
// State restoration: encode/decode path via Codable
}
}Reference: NavigationPath
Replace Boolean Sheet Triggers with Item Binding
Using .sheet(isPresented:) alongside a separate @State property for the selected item creates two sources of truth that must be kept in sync. If the boolean becomes true before the item is assigned -- or the item is changed after the sheet is already presented -- the sheet displays stale or nil data. The .sheet(item:) overload presents the sheet when the binding becomes non-nil and passes the value directly into the content closure, guaranteeing the sheet always receives the correct item.
Incorrect (boolean and item state kept separately):
struct InvoiceListView: View {
@State private var invoices: [Invoice] = []
@State private var showDetail = false
@State private var selectedInvoice: Invoice?
var body: some View {
List(invoices) { invoice in
Button(invoice.title) {
selectedInvoice = invoice
showDetail = true
// Race condition: showDetail can be true
// before selectedInvoice is set on fast taps
}
}
.sheet(isPresented: $showDetail) {
// selectedInvoice may still be nil or stale
if let invoice = selectedInvoice {
InvoiceDetailView(invoice: invoice)
}
}
}
}Correct (item binding as single source of truth):
struct InvoiceListView: View {
@State private var invoices: [Invoice] = []
@State private var selectedInvoice: Invoice?
var body: some View {
List(invoices) { invoice in
Button(invoice.title) {
selectedInvoice = invoice
// Sheet presents when selectedInvoice becomes non-nil
}
}
.sheet(item: $selectedInvoice) { invoice in
// invoice is guaranteed to be the correct, non-nil value
InvoiceDetailView(invoice: invoice)
}
}
}Reference: sheet(item:onDismiss:content:))
Use NavigationSplitView for Multi-Column Layouts
Manually checking horizontalSizeClass to switch between a sidebar layout and a stack layout is fragile: you must handle back-navigation, selection state, and animation transitions yourself, and every new size class combination multiplies the branching. NavigationSplitView provides a declarative multi-column layout that automatically collapses into a stack on compact-width devices (iPhone) and expands into two or three columns on regular-width devices (iPad), including proper back-navigation behavior with no additional code.
Incorrect (manual size class switching between layouts):
struct MailView: View {
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var selectedFolder: Folder?
@State private var selectedMessage: Message?
var body: some View {
if sizeClass == .regular {
HStack(spacing: 0) {
FolderListView(selection: $selectedFolder)
.frame(width: 280)
Divider()
if let folder = selectedFolder {
MessageListView(folder: folder, selection: $selectedMessage)
Divider()
if let message = selectedMessage {
MessageDetailView(message: message)
}
}
}
// Must manually handle back navigation, transitions,
// and selection state synchronization
} else {
NavigationStack {
FolderListView(selection: $selectedFolder)
}
}
}
}Correct (NavigationSplitView with automatic adaptation):
struct MailView: View {
@State private var selectedFolder: Folder?
@State private var selectedMessage: Message?
var body: some View {
NavigationSplitView {
FolderListView(selection: $selectedFolder)
} content: {
if let folder = selectedFolder {
MessageListView(folder: folder, selection: $selectedMessage)
} else {
Text("Select a folder")
}
} detail: {
if let message = selectedMessage {
MessageDetailView(message: message)
} else {
Text("Select a message")
}
}
// Automatically collapses to stack on iPhone
// Expands to three columns on iPad
// Back navigation handled by SwiftUI
}
}Reference: NavigationSplitView
Replace Destination-Based NavigationLink with Coordinator Route
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Destination-based NavigationLinks embed the destination view directly, tightly coupling trigger to presentation. Value-based links emit a Hashable value, but the destination should be resolved at the coordinator's NavigationStack root — not on the child view. Views request navigation through the coordinator; the coordinator maps routes to views in a single location.
Incorrect (destination view coupled directly to the link):
struct PlaylistView: View {
let songs: [Song]
var body: some View {
List(songs) { song in
NavigationLink(destination: SongDetailView(song: song)) {
SongRow(song: song)
}
}
.navigationTitle("Playlist")
}
}Also incorrect (value-based link but destination on child view):
struct PlaylistView: View {
let songs: [Song]
var body: some View {
List(songs) { song in
NavigationLink(value: song) {
SongRow(song: song)
}
}
.navigationTitle("Playlist")
.navigationDestination(for: Song.self) { song in
SongDetailView(song: song)
}
// Destination registered on child — will duplicate if this view appears
// in multiple navigation contexts
}
}Correct (coordinator owns routing, destinations at stack root):
enum MusicRoute: Hashable {
case song(Song)
case album(Album)
}
@Observable
final class MusicCoordinator {
var path = NavigationPath()
func navigate(to route: MusicRoute) {
path.append(route)
}
}
struct MusicFlowView: View {
@State private var coordinator = MusicCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
PlaylistView()
.navigationDestination(for: MusicRoute.self) { route in
switch route {
case .song(let song):
SongDetailView(song: song)
case .album(let album):
AlbumDetailView(album: album)
}
}
}
.environment(coordinator)
}
}
struct PlaylistView: View {
@Environment(MusicCoordinator.self) private var coordinator
let songs: [Song]
var body: some View {
List(songs) { song in
Button { coordinator.navigate(to: .song(song)) } label: {
SongRow(song: song)
}
}
.navigationTitle("Playlist")
}
}Reference: Advanced iOS App Architecture (4th Ed.)
Extract @Binding to Isolate Child Re-renders
When a parent passes an entire model to a child view, the child re-renders on every change to any property of that model, even properties the child never reads. By extracting a @Binding to only the specific field the child edits, you scope its dependency so it re-renders only when that field changes. This is especially important in forms where multiple children each edit one field of a shared model.
Incorrect (child depends on entire model, re-renders on any change):
struct ProfileEditor: View {
@State private var profile = UserProfile()
var body: some View {
Form {
AvatarPicker(profile: $profile)
// AvatarPicker re-renders when profile.bio,
// profile.displayName, or any other field changes
TextField("Display Name", text: $profile.displayName)
TextEditor(text: $profile.bio)
}
}
}
struct AvatarPicker: View {
@Binding var profile: UserProfile
var body: some View {
Image(profile.avatarName)
.onTapGesture { profile.avatarName = "avatar_2" }
}
}Correct (child depends only on the field it edits):
struct ProfileEditor: View {
@State private var profile = UserProfile()
var body: some View {
Form {
AvatarPicker(avatarName: $profile.avatarName)
// AvatarPicker only re-renders when avatarName changes
TextField("Display Name", text: $profile.displayName)
TextEditor(text: $profile.bio)
}
}
}
struct AvatarPicker: View {
@Binding var avatarName: String
var body: some View {
Image(avatarName)
.onTapGesture { avatarName = "avatar_2" }
}
}Reference: Binding
Use Computed Properties Over Redundant @State
When one piece of state is derived from another, storing both as separate @State properties creates a synchronization problem: the derived value can become stale if you forget to update it after every change to the source. A computed property guarantees the derived value is always consistent with its source, eliminating an entire class of bugs.
Incorrect (redundant state that can fall out of sync):
struct RegistrationForm: View {
@State private var name: String = ""
@State private var email: String = ""
@State private var isFormValid: Bool = false
var body: some View {
Form {
TextField("Name", text: $name)
.onChange(of: name) { _, _ in
isFormValid = !name.isEmpty && !email.isEmpty
}
TextField("Email", text: $email)
.onChange(of: email) { _, _ in
isFormValid = !name.isEmpty && !email.isEmpty
}
Button("Register") { submit() }
.disabled(!isFormValid)
}
}
}Correct (computed property always reflects current state):
struct RegistrationForm: View {
@State private var name: String = ""
@State private var email: String = ""
private var isFormValid: Bool {
!name.isEmpty && !email.isEmpty
}
var body: some View {
Form {
TextField("Name", text: $name)
TextField("Email", text: $email)
Button("Register") { submit() }
.disabled(!isFormValid)
}
}
}Reference: Managing model data in your app
Replace onAppear Closures with .task Modifier
Using .onAppear with a manually created Task has no built-in cancellation: if the view disappears before the async work completes, the task keeps running, wasting resources and causing updates to stale views. The .task modifier ties the task lifecycle to the view -- SwiftUI automatically cancels it when the view disappears. It also supports .task(id:) to cancel and restart the task whenever a dependency value changes.
Incorrect (manual task with no automatic cancellation):
struct ArticleView: View {
let articleID: String
@State private var content: ArticleContent?
var body: some View {
ScrollView {
if let content {
ArticleBody(content: content)
} else {
ProgressView()
}
}
.onAppear {
Task {
// This task keeps running if the view disappears,
// and does not re-trigger if articleID changes
content = await ArticleService.fetch(id: articleID)
}
}
}
}Correct (automatic cancellation and re-trigger on dependency change):
struct ArticleView: View {
let articleID: String
@State private var content: ArticleContent?
var body: some View {
ScrollView {
if let content {
ArticleBody(content: content)
} else {
ProgressView()
}
}
.task(id: articleID) {
// Cancelled automatically when view disappears
// or when articleID changes, then re-started
content = await ArticleService.fetch(id: articleID)
}
}
}Reference: task(priority:_:))
Migrate @ObservedObject to @Observable Property-Level Tracking
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
With ObservableObject, marking a dependency as @ObservedObject subscribes the view to every @Published change — even properties it never reads. With @Observable (iOS 26 / Swift 6.2), SwiftUI automatically tracks which properties each view accesses and only re-renders when those specific properties change. Replace @ObservedObject with a plain property and let @Observable handle targeted tracking.
Incorrect (@ObservedObject re-renders on every @Published change):
class OrderViewModel: ObservableObject {
@Published var items: [OrderItem] = []
@Published var deliveryAddress: String = ""
@Published var paymentMethod: String = ""
@Published var orderTotal: Decimal = 0
}
struct OrderHeader: View {
@ObservedObject var viewModel: OrderViewModel
// Re-renders when deliveryAddress, paymentMethod, or items change
// even though it only reads orderTotal
var body: some View {
Text("Total: \(viewModel.orderTotal, format: .currency(code: "USD"))")
}
}Correct (@Observable — automatic property-level tracking):
@Observable
class OrderViewModel {
var items: [OrderItem] = []
var deliveryAddress: String = ""
var paymentMethod: String = ""
var orderTotal: Decimal {
items.reduce(0) { $0 + $1.price }
}
}
struct OrderHeader: View {
var viewModel: OrderViewModel
// Only re-renders when orderTotal (computed from items) changes
// Changes to deliveryAddress or paymentMethod are ignored
var body: some View {
Text("Total: \(viewModel.orderTotal, format: .currency(code: "USD"))")
}
}Pass primitives for maximum isolation:
struct OrderHeader: View {
let orderTotal: Decimal
// Zero observation — only re-renders when parent passes new value
var body: some View {
Text("Total: \(orderTotal, format: .currency(code: "USD"))")
}
}Migration checklist:
@ObservedObject var→ plainvar(for injected @Observable)@StateObject var→@State var(for owned @Observable)- Or pass individual properties instead of entire objects for maximum isolation
Reference: Comparing @Observable to ObservableObjects
Minimize State Scope to Nearest Consumer
When @State is declared in a parent view, every change to that state invalidates the parent and all of its children, even if only one deeply nested child actually reads the value. Moving @State down to the view that consumes it confines invalidation to the smallest possible subtree, reducing re-renders by 2-5x in deep view hierarchies.
Incorrect (state owned by parent forces entire subtree to re-render):
struct SettingsScreen: View {
@State private var preferences: [Preference] = []
@State private var isBoldTextEnabled: Bool = false
var body: some View {
VStack {
BoldTextToggle(isEnabled: $isBoldTextEnabled)
PreferenceList(preferences: preferences)
// Toggling isBoldTextEnabled invalidates SettingsScreen,
// which re-renders PreferenceList even though it
// never reads isBoldTextEnabled
}
}
}
struct BoldTextToggle: View {
@Binding var isEnabled: Bool
var body: some View {
Toggle("Bold Text", isOn: $isEnabled)
}
}Correct (state scoped to the only view that consumes it):
struct SettingsScreen: View {
@State private var preferences: [Preference] = []
var body: some View {
VStack {
BoldTextToggle()
PreferenceList(preferences: preferences)
// Toggle changes only invalidate BoldTextToggle
}
}
}
struct BoldTextToggle: View {
@State private var isEnabled: Bool = false
var body: some View {
Toggle("Bold Text", isOn: $isEnabled)
}
}Reference: Demystify SwiftUI performance - WWDC23
Migrate @StateObject to @State with @Observable
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
@StateObject belongs to the ObservableObject protocol, which broadcasts ALL property changes to ALL subscribing views. With @Observable (iOS 26 / Swift 6.2), @State replaces @StateObject for ViewModel ownership, and @Environment replaces @EnvironmentObject for injection. The ownership semantics are identical — @State creates and retains the instance across view rebuilds — but observation is now property-level instead of whole-object.
Incorrect (@StateObject + @EnvironmentObject — legacy ObservableObject pattern):
@main
struct MyApp: App {
@StateObject private var store = AnalyticsStore()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(store)
}
}
}
struct DashboardTab: View {
@EnvironmentObject var store: AnalyticsStore
// Runtime crash if .environmentObject(store) is missing
// ALL views re-render when ANY @Published property changes
var body: some View {
DashboardCharts(store: store)
}
}Correct (@State + @Environment with @Observable — compile-time safety, targeted re-renders):
@Observable
class AnalyticsStore {
var events: [AnalyticsEvent] = []
var isProcessing: Bool = false
var lastSyncDate: Date?
func track(_ event: AnalyticsEvent) {
events.append(event)
}
}
@main
struct MyApp: App {
@State private var store = AnalyticsStore()
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
}
}
struct DashboardTab: View {
@Environment(AnalyticsStore.self) private var store
// Compile-time type verification
// Only re-renders when properties THIS view reads actually change
var body: some View {
DashboardCharts(events: store.events)
}
}Migration checklist:
@StateObject→@State@EnvironmentObject→@Environment.environmentObject()→.environment()ObservableObject→@Observable@Published var→ plainvar
Reference: Migrating from the Observable Object protocol to the Observable macro
Use camelCase Naming Convention
Swift uses camelCase: words are joined without spaces, and each word after the first is capitalized. Types use UpperCamelCase (PascalCase), while properties, methods, and variables use lowerCamelCase.
Incorrect (inconsistent naming):
struct user_profile { // Wrong: snake_case for type
var user_name: String // Wrong: snake_case
var ImageScale: CGFloat // Wrong: starts with uppercase
}
func GetUserData() { } // Wrong: starts with uppercase
let MAX_RETRIES = 3 // Wrong: SCREAMING_SNAKE_CASECorrect (Swift camelCase):
struct UserProfile { // UpperCamelCase for types
var userName: String // lowerCamelCase for properties
var imageScale: CGFloat // lowerCamelCase
}
func getUserData() { } // lowerCamelCase for functions
let maxRetries = 3 // lowerCamelCase for constants
// SwiftUI examples
Text("Hello")
.imageScale(.large) // Modifier uses lowerCamelCase
.foregroundStyle(.tint)Swift naming rules:
- Types (struct, class, enum, protocol):
UpperCamelCase - Properties, methods, variables:
lowerCamelCase - Constants:
lowerCamelCase(not SCREAMING_SNAKE_CASE) - Enum cases:
lowerCamelCase
Reference: Develop in Swift Tutorials - Hello, SwiftUI
Use Closures for Inline Functions
Closures are self-contained blocks of code that capture values from their context. SwiftUI uses closures extensively for button actions, list item views, and async callbacks. Use trailing closure syntax for cleaner code and prefer shorthand argument names for simple transformations.
Incorrect (named functions for simple one-off operations):
struct CounterView: View {
@State private var count = 0
func incrementCount() {
count += 1
}
func decrementCount() {
count -= 1
}
var body: some View {
HStack {
Button("−", action: decrementCount)
Text("\(count)")
Button("+", action: incrementCount)
}
}
}
// Named function for trivial array operation
func doubleValue(_ value: Int) -> Int {
return value * 2
}
let doubled = numbers.map(doubleValue)Correct (closures for inline operations, trailing syntax):
struct CounterView: View {
@State private var count = 0
var body: some View {
HStack {
Button("−") { count -= 1 }
Text("\(count)")
Button("+") { count += 1 }
}
}
}
// Closure with shorthand argument for simple transform
let doubled = numbers.map { $0 * 2 }
// Trailing closure for SwiftUI modifiers
Button {
count += 1
} label: {
Label("Increment", systemImage: "plus")
}
// Multi-line closures for complex operations
ForEach(friends) { friend in
Text(friend.name)
}Closure patterns:
- Use trailing closure syntax when last parameter is a closure
$0,$1for shorthand argument names in simple transforms- Closures capture variables from their context
@escapingfor closures stored for later execution
Reference: Develop in Swift Tutorials - Update the UI with state
Use for-in Loops for Collections
Swift's for-in loop iterates over sequences directly. Prefer it over C-style index loops - it's safer, more readable, and works with any type that conforms to Sequence.
Incorrect (index-based iteration):
let pals = ["Elisha", "Andre", "Jasmine"]
// C-style loop is verbose and error-prone
for var i = 0; i < pals.count; i += 1 {
print(pals[i])
}
// While loop for iteration
var index = 0
while index < pals.count {
print(pals[index])
index += 1
}Correct (for-in iteration):
let pals = ["Elisha", "Andre", "Jasmine"]
// Iterate directly over elements
for pal in pals {
print("Pal: \(pal)")
}
// With index when needed
for (index, pal) in pals.enumerated() {
print("\(index): \(pal)")
}
// Iterate over ranges
for number in 1...5 {
print(number) // 1, 2, 3, 4, 5
}
// Iterate over dictionary
let scores = ["Alice": 95, "Bob": 87]
for (name, score) in scores {
print("\(name): \(score)")
}for-in advantages:
- No off-by-one errors
- Works with any Sequence (arrays, sets, ranges, strings)
- Clearer intent than index manipulation
- SwiftUI uses ForEach which follows the same pattern
Related skills
FAQ
What does swift-refactor do?
swift-refactor: A skill for development. This provides functionality for development workflows.
When should I use swift-refactor?
When you need to use swift-refactor for development tasks, or when swift-refactor: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
swift-refactor.