
Swift Ui Architect
- 215 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
swift-ui-architect: A skill for development. This provides functionality for development workflows.
Key points
- swift-ui-architect
Swift Ui Architect by the numbers
- 215 all-time installs (skills.sh)
- +10 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,824 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-ui-architectAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 215 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use swift-ui-architect for development tasks?
Use swift-ui-architect for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with swift-ui-architect.
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-ui-architect for development tasks, or when swift-ui-architect: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to swift-ui-architect: swift-ui-architect.
Files
SwiftUI Modular MVVM-C Architecture
Opinionated architecture enforcement for SwiftUI clinic-style apps. This skill aligns to the iOS 26 / Swift 6.2 clinic architecture: modular MVVM-C in local SPM packages, concrete coordinators and route shells in the App target, pure Domain protocols, and Data as the only I/O layer.
Mandated Architecture Stack
┌───────────────────────────────────────────────────────────────┐
│ App target: DependencyContainer, Coordinators, Route Shells │
├───────────────┬───────────────┬───────────────┬──────────────┤
│ Feature* SPM │ Feature* SPM │ Feature* SPM │ Feature* SPM │
│ View + VM │ View + VM │ View + VM │ View + VM │
├───────────────────────────────────────────────────────────────┤
│ Data SPM: repository impls, remote/local, retry, sync queue │
├───────────────────────────────────────────────────────────────┤
│ Domain SPM: models, repository protocols, coordinator protocols│
│ and ErrorRouting/AppError │
├───────────────────────────────────────────────────────────────┤
│ Shared SPMs: DesignSystem, SharedKit │
└───────────────────────────────────────────────────────────────┘Dependency Rule: Feature modules import Domain + DesignSystem only. Features never import Data or other features. App target is the only convergence point.
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:
- Building or refactoring feature modules under local SPM packages
- Wiring coordinators, route shells, and dependency container factories
- Defining Domain protocols for repositories, coordinators, and error routing
- Enforcing Data-only ownership of networking, persistence, and sync
- Reviewing stale-while-revalidate reads and optimistic queued writes
Non-Negotiable Constraints (iOS 26 / Swift 6.2)
@Observablefor ViewModels/coordinators,ObservableObject/@Publishednever- No dedicated use-case/interactor layer: ViewModels call Domain repository protocols directly
- Coordinator protocols live in Domain; concrete coordinators own
NavigationPathin App target - Route shells live in App target and own
.navigationDestinationmapping AppError+ErrorRoutingdrive presentation policy; ViewModels do not hardcode global error UI- SwiftData / URLSession / retry / sync queue logic stays in Data package only
Rule Categories by Priority
| Priority | Category | Impact | Prefix | Rules |
|---|---|---|---|---|
| 1 | View Identity & Diffing | CRITICAL | diff- | 6 |
| 2 | State Architecture | CRITICAL | state- | 7 |
| 3 | View Composition | HIGH | view- | 6 |
| 4 | Navigation & Coordination | HIGH | nav- | 5 |
| 5 | Layer Architecture | HIGH | layer- | 6 |
| 6 | Dependency Injection | MEDIUM-HIGH | di- | 4 |
| 7 | List & Collection Performance | MEDIUM | list- | 4 |
| 8 | Async & Data Flow | MEDIUM | data- | 5 |
Quick Reference
1. View Identity & Diffing (CRITICAL)
- `diff-equatable-views` - Apply @Equatable macro to every SwiftUI view
- `diff-closure-skip` - Use @SkipEquatable for closure/handler properties
- `diff-reference-types` - Never store reference types without Equatable conformance
- `diff-identity-stability` - Use stable O(1) identifiers in ForEach
- `diff-avoid-anyview` - Never use AnyView — use @ViewBuilder or generics
- `diff-printchanges-debug` - Use _printChanges() to diagnose unnecessary re-renders
2. State Architecture (CRITICAL)
- `state-observable-class` - Use @Observable classes for all ViewModels
- `state-ownership` - @State for owned data, plain property for injected data
- `state-single-source` - One source of truth per piece of state
- `state-scoped-observation` - Leverage @Observable property-level tracking
- `state-binding-minimal` - Pass @Binding only for two-way data flow
- `state-environment-global` - Use @Environment for app-wide shared dependencies
- `state-no-published` - Never use @Published or ObservableObject
3. View Composition (HIGH)
- `view-body-complexity` - Maximum 10 nodes in view body
- `view-extract-subviews` - Extract computed properties/helpers into separate View structs
- `view-no-logic-in-body` - Zero business logic in body
- `view-minimal-dependencies` - Pass only needed properties, not entire models
- `view-viewbuilder-composition` - Use @ViewBuilder for conditional composition
- `view-no-init-sideeffects` - Never perform work in View init
4. Navigation & Coordination (HIGH)
- `nav-coordinator-pattern` - Every feature has a coordinator owning NavigationStack
- `nav-routes-enum` - Define all routes as a Hashable enum
- `nav-deeplink-support` - Coordinators must support URL-based deep linking
- `nav-modal-sheets` - Present modals via coordinator, not inline
- `nav-no-navigationlink` - Never use NavigationLink(destination:) — use navigationDestination(for:)
5. Layer Architecture (HIGH)
- `layer-dependency-rule` - Domain layer has zero framework imports
- `layer-usecase-protocol` - Do not add a use-case layer; keep orchestration in ViewModel + repository protocols
- `layer-repository-protocol` - Repository protocols in Domain, implementations in Data
- `layer-model-value-types` - Domain models are structs, never classes
- `layer-no-view-repository` - Views never access repositories directly; ViewModel calls repository protocols
- `layer-viewmodel-boundary` - ViewModels expose display-ready state only
6. Dependency Injection (MEDIUM-HIGH)
- `di-environment-injection` - Inject container-managed protocol dependencies via @Environment
- `di-protocol-abstraction` - All injected dependencies are protocol types
- `di-container-composition` - Compose
DependencyContainerin App target and expose VM factories - `di-mock-testing` - Every protocol dependency has a mock for testing
7. List & Collection Performance (MEDIUM)
- `list-constant-viewcount` - ForEach must produce constant view count per element
- `list-filter-in-model` - Filter/sort in ViewModel, never inside ForEach
- `list-lazy-stacks` - Use LazyVStack/LazyHStack for unbounded content
- `list-id-keypath` - Provide explicit id keyPath — never rely on implicit identity
8. Async & Data Flow (MEDIUM)
- `data-task-modifier` - Use
.task(id:)as the primary feature data-loading trigger - `data-async-init` - Never perform async work in init
- `data-error-loadable` - Model loading states as enum, not booleans
- `data-combine-avoid` - Prefer async/await over Combine for new code
- `data-cancellation` - Use .task automatic cancellation — never manage Tasks manually
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 |
| metadata.json | Version and reference information |
{Rule Title}
{1-3 sentences explaining WHY this matters. Focus on architectural or performance implications. Be definitive, not hedging.}
Incorrect ({what's wrong}):
{Bad code example - production-realistic, not strawman}
{// Comments explaining the cost/violation}Correct ({what's right}):
{Good code example - minimal diff from incorrect}
{// Comments explaining the benefit}When NOT to use this pattern:
- {Exception 1}
- {Exception 2}
Reference: [{Reference Title}]({Reference URL})
{
"version": "1.0.6",
"organization": "Airbnb Engineering / OLX / Apple",
"technology": "SwiftUI (iOS 26 / Swift 6.2)",
"date": "February 2026",
"abstract": "Opinionated SwiftUI architecture enforcement for iOS 26 / Swift 6.2 apps. Contains 43 rules across 8 categories: View Identity & Diffing (CRITICAL), State Architecture (CRITICAL), View Composition (HIGH), Navigation & Coordination (HIGH), Layer Architecture (HIGH), Dependency Injection (MEDIUM-HIGH), List & Collection Performance (MEDIUM), and Async & Data Flow (MEDIUM). Mandates modular MVVM-C with App-target composition root + route shells, @Observable state, Domain repository/coordinator/error protocols, Data-owned I/O, and strict feature-module dependency boundaries. Aligned with the iOS 26 / Swift 6.2 clinic modular MVVM-C architecture.",
"references": [
"https://airbnb.tech/uncategorized/understanding-and-improving-swiftui-performance/",
"https://medium.com/airbnb-engineering/unlocking-swiftui-at-airbnb-ea58f50cde49",
"https://developer.apple.com/videos/play/wwdc2023/10160/",
"https://developer.apple.com/documentation/Xcode/understanding-and-improving-swiftui-performance",
"https://nalexn.github.io/clean-architecture-swiftui/",
"https://www.amazon.com/Advanced-iOS-App-Architecture-Fourth/dp/195032561X",
"https://github.com/airbnb/swift"
]
}
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)
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. State Architecture (state)
Impact: CRITICAL Description: Wrong state ownership (@State vs plain property vs @Environment) cascades unnecessary rebuilds across the entire view hierarchy. @Observable scoping and single-source-of-truth violations multiply render cost geometrically.
3. 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.
4. 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 for scalable apps.
5. 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 beyond 50+ screens.
6. 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.
7. List & Collection Performance (list)
Impact: MEDIUM Description: Variable view counts per element, AnyView usage, and inline filtering force SwiftUI to instantiate all list items to gather identifiers, destroying scroll performance on large datasets.
8. Async & Data Flow (data)
Impact: MEDIUM Description: Synchronous data loading in init blocks app launch, missing .task usage leaks Tasks, and over-broad observation triggers cascade updates across unrelated views.
Never Perform Async Work in Init
@Observable class initializers run synchronously on the main thread. Performing async work (network calls, database queries) in init blocks the UI and runs every time the parent view re-evaluates and recreates the object. Spawning a detached Task {} inside init avoids blocking but creates an unstructured task that cannot be cancelled when the view disappears. Move all async initialization to a load() method called from .task {}.
Incorrect (Task spawned in init — runs on every parent re-render, unstructured):
@Observable
class UserProfileViewModel {
var user: User?
var isLoading = false
init(userId: String) {
// This init runs every time the PARENT view's body re-evaluates
// if the ViewModel isn't held in @State
Task {
// Detached, unstructured Task — not tied to any view lifecycle
// Cannot be cancelled when the view disappears
isLoading = true
user = try? await UserService().fetchUser(id: userId)
isLoading = false
}
}
}
struct ProfileView: View {
let userId: String
var body: some View {
// Without @State, this creates a NEW ViewModel on every body evaluation
// Each new ViewModel spawns another Task in init
let viewModel = UserProfileViewModel(userId: userId)
ProfileContent(viewModel: viewModel)
}
}
// Problems:
// 1. Parent view re-renders 5 times → 5 ViewModel inits → 5 network requests
// 2. None of these Tasks are cancelled when the view disappears
// 3. All 5 responses arrive and update state on a deallocated object
// 4. Main thread blocked during synchronous init work before Task spawnsCorrect (explicit load() method called from .task — structured, cancellable):
@Observable
class UserProfileViewModel {
var user: User?
var isLoading = false
private let userId: String
private let userService: any UserServiceProtocol
init(userId: String, userService: any UserServiceProtocol) {
// Init is synchronous and fast — only stores values
self.userId = userId
self.userService = userService
// NO async work, NO Task creation
}
func load() async {
// Called from .task — structured concurrency, auto-cancellable
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
user = try await userService.fetchUser(id: userId)
} catch {
// Handle error (CancellationError is automatically thrown on cancel)
if !Task.isCancelled {
// Only handle non-cancellation errors
self.user = nil
}
}
}
}
struct ProfileView: View {
@State var viewModel: UserProfileViewModel
// @State ensures the ViewModel survives parent re-renders
var body: some View {
ProfileContent(viewModel: viewModel)
.task {
// Structured: cancelled when view disappears
// Runs once when view appears (not on every parent re-render)
await viewModel.load()
}
}
}Key rules:
init()must be synchronous, fast, and side-effect free — store values only- All async work lives in explicit methods (
load(),refresh(),search()) .task {}provides structured concurrency with automatic cancellation@Stateon the ViewModel prevents recreation on parent re-renders
Reference: WWDC23 — Demystify SwiftUI Performance
Use .task Automatic Cancellation — Never Manage Task Lifecycle Manually
.task {} provides structured concurrency tied to the view lifecycle — it cancels automatically when the view disappears. Manual Task creation (Task { }, Task.detached { }) requires manual cancellation and risks completing after the view is gone, updating deallocated state. Use .task for all view-triggered async work. The .task(id:) variant automatically restarts when the id value changes.
Incorrect (manual Task lifecycle management — error-prone, resource leaks):
struct SearchResultsView: View {
@State var viewModel: SearchResultsViewModel
@State private var searchTask: Task<Void, Never>?
// ^^^^^^^^^ manual Task storage
var body: some View {
List(viewModel.results) { result in
ResultRow(result: result)
}
.searchable(text: $viewModel.query)
.onChange(of: viewModel.query) { _, newQuery in
// Manual cancel + recreate on every keystroke
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await viewModel.search(query: newQuery)
}
}
.onDisappear {
// Easy to forget — if omitted, Task runs after view is gone
searchTask?.cancel()
searchTask = nil
}
}
}
// Problems:
// 1. Forgetting .onDisappear cancel → leaked Task, use-after-disappear
// 2. Race condition: onChange fires before onDisappear processes cancel
// 3. @State var searchTask adds view state that has nothing to do with UI
// 4. Every caller must remember the cancel/recreate danceCorrect (.task with automatic cancellation — zero manual management):
struct SearchResultsView: View {
@State var viewModel: SearchResultsViewModel
var body: some View {
List(viewModel.results) { result in
ResultRow(result: result)
}
.searchable(text: $viewModel.query)
.task(id: viewModel.query) {
// .task(id:) handles everything:
// 1. Cancels the previous task when query changes
// 2. Waits for debounce period
// 3. Starts new search
// 4. Auto-cancels when view disappears
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await viewModel.search(query: viewModel.query)
}
// No @State var task, no .onDisappear, no manual cancel
}
}Correct (.task(id:) for dependent data reloading):
struct ProductListView: View {
@State var viewModel: ProductListViewModel
@State private var selectedCategory: Category = .all
@State private var sortOrder: SortOrder = .newest
var body: some View {
VStack {
CategoryPicker(selection: $selectedCategory)
SortPicker(selection: $sortOrder)
ProductGrid(products: viewModel.products)
// Combine multiple dependencies into a single hashable value
.task(id: FilterKey(category: selectedCategory, sort: sortOrder)) {
await viewModel.loadProducts(
category: selectedCategory,
sort: sortOrder
)
}
}
}
}
// Hashable struct to combine multiple task dependencies
private struct FilterKey: Hashable {
let category: Category
let sort: SortOrder
}
// Lifecycle:
// 1. View appears → .task starts → loadProducts runs
// 2. User changes category → id changes → previous task cancelled → new task starts
// 3. User changes sort → id changes → previous task cancelled → new task starts
// 4. User navigates away → view disappears → current task cancelled automaticallyKey guarantees of .task:
- Starts when view appears (or when
idchanges) - Cancels when view disappears (or when
idchanges before restart) - Cooperative cancellation:
awaitcheckpoints respond toTask.isCancelled - No stored
Taskreferences, no.onDisappearcleanup, no race conditions
Reference: Apple Documentation — View fundamentals
Prefer async/await Over Combine for New Code
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 iOS 26 / Swift 6.2 and Swift 5.9+, async/await with structured concurrency replaces most Combine use cases. async/await provides linear code flow (easier to read and debug), built-in cancellation via Task and .task {}, and proper error propagation with try/catch. Reserve Combine only for reactive streams where you need operators like debounce, throttle, or combineLatest.
Incorrect (Combine for simple fetch-and-display — unnecessary complexity):
import Combine
class SearchViewModel: ObservableObject {
@Published var query = ""
@Published var results: [SearchResult] = []
@Published var isLoading = false
@Published var errorMessage: String?
private var cancellables = Set<AnyCancellable>()
private let searchService: SearchService
init(searchService: SearchService) {
self.searchService = searchService
// 15+ lines of Combine pipeline for a simple search
$query
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.filter { !$0.isEmpty }
.flatMap { [weak self] query -> AnyPublisher<[SearchResult], Never> in
guard let self else {
return Just([]).eraseToAnyPublisher()
}
self.isLoading = true
return searchService.search(query: query)
.catch { error -> Just<[SearchResult]> in
self.errorMessage = error.localizedDescription
return Just([])
}
.eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.sink { [weak self] results in
self?.results = results
self?.isLoading = false
}
.store(in: &cancellables)
}
// Hard to debug: breakpoints in closures, weak self everywhere,
// error handling interleaved with data flow, type erasure obscures types
}Correct (async/await — linear, readable, debuggable):
@Observable
class SearchViewModel {
var query = ""
var results: [SearchResult] = []
var isLoading = false
var errorMessage: String?
private let searchService: any SearchServiceProtocol
init(searchService: any SearchServiceProtocol) {
self.searchService = searchService
}
// Clear, linear code — reads top-to-bottom like synchronous code
func search() async {
let currentQuery = query
guard !currentQuery.isEmpty else {
results = []
return
}
isLoading = true
defer { isLoading = false }
do {
// Cancellation is automatic via structured concurrency
let searchResults = try await searchService.search(query: currentQuery)
// Check if query changed while we were fetching
guard query == currentQuery else { return }
results = searchResults
errorMessage = nil
} catch is CancellationError {
// Task was cancelled (e.g., view disappeared) — do nothing
} catch {
errorMessage = error.localizedDescription
}
}
}
struct SearchView: View {
@State var viewModel: SearchViewModel
var body: some View {
List(viewModel.results) { result in
SearchResultRow(result: result)
}
.searchable(text: $viewModel.query)
.task(id: viewModel.query) {
// Built-in debounce alternative: wait before searching
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await viewModel.search()
}
// .task(id:) cancels the previous task when query changes
// — acts as removeDuplicates + debounce + flatMapLatest combined
}
}When Combine IS still appropriate:
// Real-time streams where Combine operators add genuine value
import Combine
class BluetoothSensorViewModel {
private var cancellables = Set<AnyCancellable>()
init(sensor: BluetoothSensor) {
// combineLatest, throttle, scan — genuine reactive stream processing
sensor.heartRatePublisher
.combineLatest(sensor.cadencePublisher)
.throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true)
.scan(WorkoutStats()) { stats, values in
stats.updated(heartRate: values.0, cadence: values.1)
}
.sink { [weak self] stats in
self?.currentStats = stats
}
.store(in: &cancellables)
}
}
// WebSocket, Bluetooth, sensor data, multi-source merging — Combine shines hereDecision rule: If your code does fetch → transform → display, use async/await. If your code merges multiple continuous streams with time-based operators, use Combine.
Reference: Apple Documentation — Concurrency
Model Loading States as Enum, Not Booleans
Replace scattered boolean flags (isLoading, hasError, hasData) with a single Loadable<T> enum with cases: .idle, .loading, .loaded(T), .error(Error). This eliminates impossible state combinations, makes exhaustive switch statements enforce handling all cases, and provides a consistent pattern across all data-loading ViewModels.
Incorrect (boolean flags — allows impossible state combinations):
@Observable
class ArticleListViewModel {
var articles: [Article] = []
var isLoading = false
var error: Error?
var hasLoaded = false
func loadArticles() async {
isLoading = true
error = nil
do {
articles = try await articleRepository.fetchAll()
hasLoaded = true
} catch {
self.error = error
}
isLoading = false
}
}
struct ArticleListView: View {
@State var viewModel: ArticleListViewModel
var body: some View {
VStack {
// Impossible to handle all states correctly
// What if isLoading == true AND error != nil? (bug: forgot to clear error)
// What if hasLoaded == true AND articles.isEmpty? (no data vs not loaded)
// What if isLoading == false AND hasLoaded == false AND error == nil? (idle? bug?)
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.error {
ErrorView(error: error)
} else if viewModel.articles.isEmpty {
// Is this "no articles exist" or "hasn't loaded yet"?
Text("No articles") // BUG: shows before first load
} else {
ArticleList(articles: viewModel.articles)
}
}
}
}Correct (Loadable<T> enum — exactly one state at a time):
// Reusable generic enum — used across all ViewModels
enum Loadable<T> {
case idle
case loading
case loaded(T)
case error(Error)
var value: T? {
if case .loaded(let value) = self { return value }
return nil
}
var isLoading: Bool {
if case .loading = self { return true }
return false
}
}
@Observable
class ArticleListViewModel {
var articles: Loadable<[Article]> = .idle
// Single property — impossible to have conflicting states
func loadArticles() async {
articles = .loading
do {
let result = try await articleRepository.fetchAll()
articles = .loaded(result)
} catch {
articles = .error(error)
}
}
}
struct ArticleListView: View {
@State var viewModel: ArticleListViewModel
var body: some View {
// Exhaustive switch — compiler forces handling of every case
Group {
switch viewModel.articles {
case .idle:
// Clearly distinct from "loaded but empty"
Color.clear
case .loading:
ProgressView("Loading articles...")
case .loaded(let articles):
if articles.isEmpty {
ContentUnavailableView(
"No Articles",
systemImage: "doc.text",
description: Text("Articles you save will appear here.")
)
} else {
List(articles) { article in
ArticleRow(article: article)
}
}
case .error(let error):
ContentUnavailableView {
Label("Error", systemImage: "exclamationmark.triangle")
} description: {
Text(error.localizedDescription)
} actions: {
Button("Retry") {
Task { await viewModel.loadArticles() }
}
}
}
}
.task { await viewModel.loadArticles() }
}
}Key benefits:
- Exactly one state at any time —
.loadingand.errorcan never coexist - Exhaustive
switch— adding a new case produces a compiler error until all views handle it .idlevs.loaded([])distinction — "not loaded yet" vs "loaded but empty" are explicitly different states- Reusable
Loadable<T>works for any data type across the entire app
Reference: Clean Architecture for SwiftUI
Use .task {} for Async Data Loading
The .task {} modifier is the standard way to perform async work tied to a view's lifecycle. It starts when the view appears and AUTOMATICALLY cancels when the view disappears. This prevents leaked network requests, race conditions from stale responses, and manual Task lifecycle management. Prefer .task over .onAppear + Task {}.
Incorrect (.onAppear with manual Task — continues after view disappears):
struct UserProfileView: View {
@State var viewModel: UserProfileViewModel
var body: some View {
ProfileContent(user: viewModel.user)
.onAppear {
// Task created here is DETACHED from the view lifecycle
// If user navigates away, this Task keeps running
Task {
await viewModel.loadProfile()
// View may be deallocated by the time this completes
// Setting state on a gone view = wasted work or crash
}
}
}
}
// Problems:
// 1. User taps profile → loadProfile starts
// 2. User taps back immediately → view disappears
// 3. loadProfile still running → response arrives → tries to update gone view
// 4. No automatic cancellation — network request completes wastefully
// 5. If user re-enters profile, ANOTHER Task starts (both running simultaneously)Correct (.task — automatically cancelled on view disappear):
struct UserProfileView: View {
@State var viewModel: UserProfileViewModel
var body: some View {
ProfileContent(user: viewModel.user)
.task {
// Structured concurrency: tied to view lifecycle
// Automatically cancelled when view disappears
await viewModel.loadProfile()
}
}
}
// Lifecycle:
// 1. View appears → .task starts → loadProfile begins
// 2. User taps back → view disappears → .task is CANCELLED
// 3. Any awaited work inside loadProfile receives cancellation
// 4. No wasted network requests, no stale responsesCorrect (.task(id:) — auto-restarts when dependency changes):
struct CategoryProductsView: View {
@State var viewModel: ProductsViewModel
@State private var selectedCategory: Category = .all
var body: some View {
VStack {
CategoryPicker(selection: $selectedCategory)
ProductGrid(products: viewModel.products)
.task(id: selectedCategory) {
// Runs when view appears AND when selectedCategory changes
// Previous task is cancelled before new one starts
await viewModel.loadProducts(category: selectedCategory)
}
}
}
}
// Flow when user switches categories:
// 1. selectedCategory changes from .electronics to .clothing
// 2. .task(id:) detects the id changed
// 3. Previous loadProducts(.electronics) is CANCELLED
// 4. New loadProducts(.clothing) starts
// 5. No race condition — only one request active at a timeKey benefits:
- Automatic cancellation on view disappear — zero manual lifecycle code
.task(id:)auto-cancels and restarts when the observed value changes- Cooperative cancellation:
awaitcheckpoints in your async code respond to cancellation - No need for
@State private var loadTask: Task<Void, Never>?patterns
Reference: Apple Documentation — View fundamentals
Compose Dependency Container at App Root
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.
All live dependencies are created and composed at the App struct level (@main). This is the "composition root" — the single place where concrete implementations are wired together. Feature modules only know about protocols. This pattern makes the dependency graph explicit, prevents hidden singletons, and ensures proper lifecycle management through SwiftUI's state system.
Incorrect (ViewModels creating their own dependencies — hidden composition, duplicated instances):
// Each ViewModel creates its own dependencies internally
@Observable
class ProfileViewModel {
// Hidden dependency creation — not visible to callers
private let networkService = NetworkService()
private let userRepository = UserRepository(
networkService: NetworkService() // ANOTHER instance — duplicated!
)
private let analyticsService = AnalyticsService.shared // hidden singleton
func loadProfile() async {
let user = try? await userRepository.fetchCurrentUser()
analyticsService.track(.profileViewed)
}
}
@Observable
class SettingsViewModel {
// Same dependencies created AGAIN — different instances, no shared state
private let userRepository = UserRepository(
networkService: NetworkService()
)
private let analyticsService = AnalyticsService.shared
func updateSettings() async {
// This userRepository has a DIFFERENT cache than ProfileViewModel's
}
}
// App struct has no visibility into what dependencies exist
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView() // no idea what's happening inside
}
}
}Correct (composition root at @main — all dependencies created and injected once):
// Dependency container holds all live implementations
@Observable
class AppDependencies {
let networkService: any NetworkServiceProtocol
let userRepository: any UserRepositoryProtocol
let analyticsService: any AnalyticsServiceProtocol
let settingsRepository: any SettingsRepositoryProtocol
init() {
// Single creation point — shared instances, explicit wiring
let network = NetworkService(baseURL: Config.apiBaseURL)
self.networkService = network
self.userRepository = UserRepository(networkService: network)
self.analyticsService = AnalyticsService(apiKey: Config.analyticsKey)
self.settingsRepository = SettingsRepository(networkService: network)
}
}
// App struct is the composition root — injects everything
@main
struct MyApp: App {
@State private var dependencies = AppDependencies()
var body: some Scene {
WindowGroup {
AppCoordinatorView()
.environment(\.networkService, dependencies.networkService)
.environment(\.userRepository, dependencies.userRepository)
.environment(\.analyticsService, dependencies.analyticsService)
.environment(\.settingsRepository, dependencies.settingsRepository)
}
}
}
// ViewModels receive dependencies — never create them
@Observable
class ProfileViewModel {
private let userRepository: any UserRepositoryProtocol
private let analyticsService: any AnalyticsServiceProtocol
init(
userRepository: any UserRepositoryProtocol,
analyticsService: any AnalyticsServiceProtocol
) {
self.userRepository = userRepository
self.analyticsService = analyticsService
}
}Key benefits:
- Entire dependency graph is visible in one place
- Shared instances (e.g.,
networkService) are created once and reused - Swapping implementations (e.g., for staging/production) is a single-line change
- No hidden singletons — everything flows through the composition root
Reference: Advanced iOS App Architecture (4th Ed.)
Inject Dependencies via @Environment With Custom EnvironmentKey
SwiftUI's @Environment is the native dependency injection mechanism. Define custom EnvironmentKey types for services (repositories, coordinators, error routing) and inject them at the app root. Views read dependencies with @Environment(\.userRepository) — no constructor parameter needed. Use the @Entry macro (Xcode 16+) to eliminate the boilerplate of defining a key and extension separately.
Incorrect (passing repository through 4 levels of view init parameters):
// Repository drilled through every intermediate view
struct AppRootView: View {
let userRepository: UserRepository
var body: some View {
TabView {
// Level 1: pass down
HomeTab(userRepository: userRepository)
}
}
}
struct HomeTab: View {
let userRepository: UserRepository // doesn't use it, just passes it
var body: some View {
NavigationStack {
// Level 2: pass down again
HomeScreen(userRepository: userRepository)
}
}
}
struct HomeScreen: View {
let userRepository: UserRepository // still doesn't use it
var body: some View {
// Level 3: pass down yet again
ProfileSummary(userRepository: userRepository)
}
}
struct ProfileSummary: View {
let userRepository: UserRepository // finally uses it
var body: some View {
Text(userRepository.currentUser?.name ?? "Guest")
}
}Correct (@Entry macro shorthand — Xcode 16+, minimal boilerplate):
// @Entry macro generates EnvironmentKey + extension in one declaration
extension EnvironmentValues {
@Entry var userRepository: any UserRepositoryProtocol = LiveUserRepository()
}
// App root injects the live implementation once
@main
struct MyApp: App {
let userRepository: any UserRepositoryProtocol = LiveUserRepository()
var body: some Scene {
WindowGroup {
AppRootView()
.environment(\.userRepository, userRepository)
}
}
}
// Any view at ANY depth reads the dependency directly — no drilling
struct ProfileSummary: View {
@Environment(\.userRepository) var userRepository
var body: some View {
Text(userRepository.currentUser?.name ?? "Guest")
}
}Correct (traditional EnvironmentKey definition — pre-Xcode 16 or explicit control):
// Step 1: Define the EnvironmentKey with a default value
private struct UserRepositoryKey: EnvironmentKey {
static let defaultValue: any UserRepositoryProtocol = LiveUserRepository()
}
// Step 2: Extend EnvironmentValues with a computed property
extension EnvironmentValues {
var userRepository: any UserRepositoryProtocol {
get { self[UserRepositoryKey.self] }
set { self[UserRepositoryKey.self] = newValue }
}
}
// Usage is identical to the @Entry version
struct ProfileSummary: View {
@Environment(\.userRepository) var userRepository
var body: some View {
Text(userRepository.currentUser?.name ?? "Guest")
}
}Key benefits:
- No parameter drilling — intermediate views are completely unaware of dependencies they don't use
- Testing: inject mocks via
.environment(\.userRepository, MockUserRepository())in previews and tests @Entrymacro reduces the traditional 3-step EnvironmentKey pattern to a single line
Reference: Apple Documentation — Environment values
Every Protocol Dependency Has a Mock for Testing
For every protocol dependency in the app, provide a Mock implementation in the test target. Mocks should record method calls (for verification), return configurable responses, and optionally throw errors (for error path testing). This enables fast, isolated, deterministic unit tests for ViewModels and UseCases.
Incorrect (unit test using real service — slow, flaky, non-deterministic):
// Test that hits the real API — slow, requires network, results vary
final class ProfileViewModelTests: XCTestCase {
func testLoadProfile() async {
// Real service — makes actual HTTP requests
let realService = UserService(baseURL: "https://api.example.com")
let viewModel = ProfileViewModel(userService: realService)
await viewModel.loadProfile()
// Flaky: depends on network, API availability, test data state
XCTAssertNotNil(viewModel.user)
// Can't test error paths without breaking the real server
// Can't verify which methods were called
// Takes 200ms+ per test instead of <1ms
}
}Correct (mock with call recording and configurable responses):
// Mock in test target — records calls and returns configured responses
final class MockUserService: UserServiceProtocol {
// Call recording — verify what was called and with what arguments
var fetchCurrentUserCallCount = 0
var updateProfileCallCount = 0
var updateProfileLastArgument: ProfileUpdate?
// Configurable responses — control what the mock returns
var fetchCurrentUserResult: Result<User, Error> = .success(
User(id: "1", name: "Test User", email: "test@example.com")
)
var updateProfileResult: Result<User, Error> = .success(
User(id: "1", name: "Updated", email: "test@example.com")
)
func fetchCurrentUser() async throws -> User {
fetchCurrentUserCallCount += 1
return try fetchCurrentUserResult.get()
}
func updateProfile(_ profile: ProfileUpdate) async throws -> User {
updateProfileCallCount += 1
updateProfileLastArgument = profile
return try updateProfileResult.get()
}
}
// Tests are fast, isolated, and deterministic
final class ProfileViewModelTests: XCTestCase {
private var mockService: MockUserService!
private var viewModel: ProfileViewModel!
override func setUp() {
mockService = MockUserService()
viewModel = ProfileViewModel(userService: mockService)
}
func testLoadProfileSuccess() async {
let expectedUser = User(id: "42", name: "Alice", email: "alice@test.com")
mockService.fetchCurrentUserResult = .success(expectedUser)
await viewModel.loadProfile()
XCTAssertEqual(mockService.fetchCurrentUserCallCount, 1)
XCTAssertEqual(viewModel.user?.name, "Alice")
XCTAssertEqual(viewModel.state, .loaded)
}
func testLoadProfileError() async {
// Configure mock to throw — tests error path without a real server
mockService.fetchCurrentUserResult = .failure(URLError(.notConnectedToInternet))
await viewModel.loadProfile()
XCTAssertEqual(mockService.fetchCurrentUserCallCount, 1)
XCTAssertNil(viewModel.user)
XCTAssertEqual(viewModel.state, .error)
}
func testUpdateProfilePassesCorrectArgument() async throws {
let update = ProfileUpdate(name: "New Name", bio: "New bio")
try await viewModel.updateProfile(update)
XCTAssertEqual(mockService.updateProfileCallCount, 1)
XCTAssertEqual(mockService.updateProfileLastArgument?.name, "New Name")
}
}Mock pattern checklist:
- Call count tracking for each method (verify calls were made)
- Argument capture for methods with parameters (verify correct data passed)
- Configurable
Result<Success, Error>for each method (control success/failure) - Default success values so tests only configure what they're testing
Reference: Clean Architecture for SwiftUI
All Injected Dependencies Are Protocol Types
Every dependency injected via @Environment or init must be typed as a protocol, never a concrete class. This enables test doubles (mocks, stubs, fakes) to be injected without changing view or ViewModel code. The EnvironmentKey's defaultValue can be a live implementation, but the type annotation must always be the protocol. Use any ProtocolName for existential types in Swift 5.9+.
Incorrect (concrete class type — impossible to substitute for testing):
// EnvironmentKey typed to concrete class
extension EnvironmentValues {
@Entry var userService: UserService = UserService()
// ^^^^^^^^^^^ concrete class — locked in
}
@Observable
class ProfileViewModel {
// Concrete type — cannot inject a mock without subclassing
let userService: UserService
init(userService: UserService) {
self.userService = userService
}
func loadProfile() async {
// Always hits the real network — tests are slow and flaky
let user = try? await userService.fetchCurrentUser()
}
}
// Tests MUST use the real UserService or resort to fragile subclass overridesCorrect (protocol type — mocks inject cleanly):
// Protocol defines the contract
protocol UserServiceProtocol {
func fetchCurrentUser() async throws -> User
func updateProfile(_ profile: ProfileUpdate) async throws -> User
}
// Live implementation conforms to the protocol
final class UserService: UserServiceProtocol {
func fetchCurrentUser() async throws -> User {
// Real network call
}
func updateProfile(_ profile: ProfileUpdate) async throws -> User {
// Real network call
}
}
// EnvironmentKey typed to protocol — any conforming type can be injected
extension EnvironmentValues {
@Entry var userService: any UserServiceProtocol = UserService()
// ^^^^^^^^^^^^^^^^^^^^^^^ protocol type
}
@Observable
class ProfileViewModel {
private let userService: any UserServiceProtocol
init(userService: any UserServiceProtocol) {
self.userService = userService
}
func loadProfile() async {
let user = try? await userService.fetchCurrentUser()
}
}
// In views — protocol type flows through @Environment
struct ProfileView: View {
@Environment(\.userService) var userService
var body: some View {
ProfileContent()
.task {
// userService is protocol-typed — mock or live, view doesn't care
}
}
}Key rules:
any ProtocolNameis required in Swift 5.9+ for existential protocol types- The protocol lives in the Domain layer; the concrete implementation lives in the Data layer
- Views and ViewModels NEVER import or reference the concrete type directly
Reference: Clean Architecture for SwiftUI
Never Use AnyView — Use @ViewBuilder or Generic Constraints
AnyView erases the concrete view type, preventing SwiftUI from performing structural identity matching. The diff engine must tear down and recreate the entire subtree on every update. Use @ViewBuilder for conditional composition or generic constraints for heterogeneous view types.
Incorrect (AnyView type erasure — full subtree teardown on every update):
struct ContentSection: View {
let style: SectionStyle
var body: some View {
sectionContent()
}
// AnyView destroys type information
// SwiftUI can't structurally diff between branches
func sectionContent() -> AnyView {
switch style {
case .hero:
return AnyView(HeroView())
case .grid:
return AnyView(GridView())
case .list:
return AnyView(ListView())
}
}
}Correct (@ViewBuilder — SwiftUI can structurally diff each branch):
struct ContentSection: View {
let style: SectionStyle
var body: some View {
sectionContent
}
// @ViewBuilder preserves concrete types
// SwiftUI tracks each branch independently
@ViewBuilder
var sectionContent: some View {
switch style {
case .hero:
HeroView()
case .grid:
GridView()
case .list:
ListView()
}
}
}Alternative (generics for stored view properties):
// Generic constraint preserves the concrete type
struct Card<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading) {
Text(title).font(.headline)
content()
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}Reference: WWDC23 — Demystify SwiftUI Performance
Use @SkipEquatable for Closure and Handler Properties
Closures cannot be compared for equality. A single closure property makes the entire view non-diffable. Mark closures and action handlers with @SkipEquatable to exclude them from the generated Equatable conformance while keeping the rest of the view diffable.
Incorrect (@Equatable with closure — build error or non-diffable):
@Equatable
struct ActionRow: View {
let title: String
let subtitle: String
let onTap: () -> Void // closure — NOT Equatable
let onLongPress: () -> Void // closure — NOT Equatable
// Build error: type 'ActionRow' does not conform to 'Equatable'
// because () -> Void is not Equatable
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(title)
Text(subtitle).foregroundStyle(.secondary)
}
Spacer()
}
.onTapGesture(perform: onTap)
.onLongPressGesture(perform: onLongPress)
}
}Correct (@SkipEquatable on closures — view remains diffable for data properties):
@Equatable
struct ActionRow: View {
let title: String
let subtitle: String
@SkipEquatable let onTap: () -> Void
@SkipEquatable let onLongPress: () -> Void
// Equatable generated for title + subtitle only
// Body re-evaluates only when title or subtitle changes
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(title)
Text(subtitle).foregroundStyle(.secondary)
}
Spacer()
}
.onTapGesture(perform: onTap)
.onLongPressGesture(perform: onLongPress)
}
}Alternative: If the closure captures data that determines equivalence, wrap it in an Equatable action type:
enum CardAction: Equatable {
case addToCart(productID: String)
case removeFromCart(productID: String)
}
@Equatable
struct ActionCard: View {
let title: String
let action: CardAction // Equatable — no @SkipEquatable needed
@SkipEquatable let perform: (CardAction) -> Void
var body: some View { /* ... */ }
}Reference: Airbnb Engineering — @Equatable macro
Apply @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. The @Equatable macro generates Equatable conformance for all stored properties, excluding @State/@Environment wrappers. Build fails if a non-Equatable property is added — acting as a compile-time performance linter.
Incorrect (no @Equatable — SwiftUI can't diff, body always re-evaluates):
// Mixed property types: struct + closure
// SwiftUI's reflection can't diff this — body runs on EVERY parent update
struct ProductCard: View {
let product: Product // struct — diffable
let onAddToCart: () -> Void // closure — NOT diffable
var body: some View {
VStack {
Text(product.name)
Text(product.price, format: .currency(code: "USD"))
Button("Add to Cart", action: onAddToCart)
}
}
}Correct (@Equatable macro — guaranteed diffable, body only re-evaluates when data changes):
@Equatable
struct ProductCard: View {
let product: Product // struct — included in Equatable
@SkipEquatable
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)
}
}
}Prerequisite: The @Equatable macro requires the `ordo-one/equatable` SPM package (or equivalent). This is NOT built into SwiftUI. Add it via Package.swift or Xcode's package manager. The open-source package uses @EquatableIgnored instead of @SkipEquatable (which is Airbnb's internal name).
Alternative (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"))
}
}
}
// Use with .equatable() modifier or EquatableView wrapperBenefits:
- Compile-time guarantee: adding a non-Equatable property without
@EquatableIgnoredfails the build - Body only re-evaluates when
productactually changes @Stateand@Environmentwrappers are automatically excluded from comparison
Reference: Airbnb Engineering — Understanding and Improving SwiftUI Performance
Use Stable O(1) Identifiers in ForEach
SwiftUI uses identifiers to track view lifecycle across updates. If identifiers change between renders (e.g., using array index or UUID()), SwiftUI destroys and recreates views instead of updating them, losing @State and animation context. Use stable, model-derived identifiers.
Incorrect (array index as id — state loss and full rebuild on reorder):
struct MessageList: View {
let messages: [Message]
var body: some View {
List {
// Array index changes when items are inserted/deleted/reordered
// SwiftUI destroys and recreates ALL views below the change point
ForEach(Array(messages.enumerated()), id: \.offset) { index, message in
MessageRow(message: message)
}
}
}
}Incorrect (UUID() generated inline — every render creates new identities):
struct MessageList: View {
let messages: [Message]
var body: some View {
List {
// UUID() creates a NEW identity on every body evaluation
// Every view is destroyed and recreated every time
ForEach(messages, id: \.self.hashValue) { message in
MessageRow(message: message)
}
}
}
}Correct (stable model-derived ID via Identifiable):
struct Message: Identifiable {
let id: String // stable database/server ID — never changes for same item
let content: String
let timestamp: Date
}
struct MessageList: View {
let messages: [Message]
var body: some View {
List {
// Stable ID — SwiftUI tracks each view across updates
// Insertions/deletions animate correctly, @State is preserved
ForEach(messages) { message in
MessageRow(message: message)
}
}
}
}When NOT to use: If items genuinely have no stable identity (e.g., random decorative elements), UUID stored as a property (not generated inline) is acceptable.
Reference: WWDC23 — Demystify SwiftUI Performance
Use _printChanges() to Diagnose Unnecessary Body Evaluations
When a view re-renders unexpectedly, add let _ = Self._printChanges() at the top of the body to log which dependency triggered the update. This reveals over-broad dependencies, non-diffable properties, and cascade re-renders. NEVER ship to production — the underscore prefix indicates an unstable API.
Incorrect (guessing at re-render causes — adding optimization without evidence):
struct ProductList: View {
let viewModel: ProductListViewModel
var body: some View {
// "It feels slow, let me add caching everywhere"
// No evidence of what's actually causing re-renders
List {
ForEach(viewModel.products) { product in
ProductRow(product: product)
}
}
}
}Correct (_printChanges() in debug — identify the root cause, then fix it):
struct ProductList: View {
let viewModel: ProductListViewModel
var body: some View {
// Step 1: Add _printChanges() to identify the trigger
let _ = Self._printChanges()
// Console output examples:
// "ProductList: @self changed." → view struct itself was recreated
// "ProductList: _viewModel changed." → viewModel reference changed
// "ProductList: @identity changed." → structural identity changed
List {
ForEach(viewModel.products) { product in
ProductRow(product: product)
}
}
}
}
// Step 2: Read the log and fix the root cause
// "@self changed" → parent is recreating this view (check @Equatable)
// "_viewModel changed" → new ViewModel instance each render (check @State ownership)
// "@identity changed" → view position in hierarchy shifted (check conditional logic)When NOT to use: Production builds — _printChanges() has runtime overhead and is not API-stable. Remove before shipping.
Reference: WWDC23 — Demystify SwiftUI Performance
Never Store Reference Types Without Equatable Conformance
SwiftUI compares reference types (classes) by reference identity, not by value. Two class instances with identical data but different memory addresses will always compare as "changed", triggering unnecessary body re-evaluation. Either use value types (structs), add Equatable conformance to classes, or use @SkipEquatable if the property doesn't affect rendering.
Incorrect (class without Equatable — body always re-evaluates even when data is identical):
// Non-Equatable class — compared by reference identity
class UserProfile {
var name: String
var avatarURL: URL
init(name: String, avatarURL: URL) {
self.name = name
self.avatarURL = avatarURL
}
}
@Equatable
struct ProfileHeader: View {
let profile: UserProfile // reference type without Equatable
// SwiftUI compares by identity (===), NOT by value
// Every new instance triggers body re-evaluation even if data is the same
var body: some View {
HStack {
AsyncImage(url: profile.avatarURL)
Text(profile.name)
}
}
}Correct (option 1 — use a value type):
struct UserProfile: Equatable {
let name: String
let avatarURL: URL
}
@Equatable
struct ProfileHeader: View {
let profile: UserProfile // value type — compared by value
var body: some View {
HStack {
AsyncImage(url: profile.avatarURL)
Text(profile.name)
}
}
}Correct (option 2 — add Equatable to the class):
class UserProfile: Equatable {
let name: String
let avatarURL: URL
static func == (lhs: UserProfile, rhs: UserProfile) -> Bool {
lhs.name == rhs.name && lhs.avatarURL == rhs.avatarURL
}
init(name: String, avatarURL: URL) {
self.name = name
self.avatarURL = avatarURL
}
}Correct (option 3 — @SkipEquatable if property doesn't affect rendering):
@Equatable
struct ProfileHeader: View {
let displayName: String
@SkipEquatable let analytics: AnalyticsTracker // class, not rendered
var body: some View {
Text(displayName)
}
}Reference: WWDC23 — Demystify SwiftUI Performance
Domain Layer Has Zero Framework Imports
The Domain layer contains domain models, repository/coordinator protocols, and error types. It MUST NOT import SwiftUI, UIKit, CoreData, SwiftData, or any third-party framework. import Foundation is acceptable for standard types (Date, URL, UUID, Decimal, Data, Codable) but NEVER for networking (URLSession) or persistence. This ensures domain logic is testable without simulators, portable across platforms, and independent of Apple's framework changes.
Incorrect (domain layer importing frameworks — coupled, non-portable, requires simulator to test):
// Domain/UseCases/FetchUserProfileUseCase.swift
import SwiftUI // Framework import in domain — violates dependency rule
import Foundation // URLSession dependency leaks into domain
import SwiftData // Persistence framework in domain — non-portable
class FetchUserProfileUseCase {
// Direct framework dependency — requires network to test
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)
let profile = try JSONDecoder().decode(UserProfile.self, from: data)
return profile
}
}
// Domain model coupled to SwiftData — requires framework to compile
@Model
class UserProfile {
var id: String
var name: String
@Attribute(.spotlight) var bio: String // SwiftData attribute in domain
@Relationship var orders: [Order] // SwiftData relationship in domain
// SwiftUI dependency in domain model
var displayColor: Color {
isPremium ? .gold : .primary
}
}Correct (pure Swift domain — zero framework imports, testable anywhere):
// Domain/UseCases/FetchUserProfileUseCase.swift
// No imports — pure Swift only
// Compiles and tests without Xcode, simulators, or Apple frameworks
protocol FetchUserProfileUseCase {
func execute(userId: String) async throws -> UserProfile
}
final class FetchUserProfileUseCaseImpl: FetchUserProfileUseCase {
private let userRepository: UserRepository
init(userRepository: UserRepository) {
self.userRepository = userRepository
}
// Business logic only — no networking, no persistence, no UI
func execute(userId: String) async throws -> UserProfile {
let user = try await userRepository.fetchUser(id: userId)
// Domain logic: enrich profile with computed business rules
return UserProfile(
id: user.id,
name: user.name,
bio: user.bio,
membershipTier: MembershipTier(from: user.joinDate),
canAccessPremiumContent: user.subscription.isActive
)
}
}
// Domain/Models/UserProfile.swift
// Pure Swift struct — no framework annotations
struct UserProfile: Equatable, Sendable {
let id: String
let name: String
let bio: String
let membershipTier: MembershipTier
let canAccessPremiumContent: Bool
}
// Domain/Repositories/UserRepository.swift
// Protocol in domain — implementation in Data layer
protocol UserRepository: Sendable {
func fetchUser(id: String) async throws -> User
func saveUser(_ user: User) async throws
}Reference: Clean Architecture for SwiftUI
Domain Models Are Structs, Never Classes
Domain models (User, Order, Product) must be structs conforming to Equatable and Sendable. Value types have predictable copy semantics — mutations in one view model never unexpectedly affect another. They're also naturally diffable by SwiftUI, thread-safe by default, and trivially serializable.
Incorrect (class model with var properties — shared mutable state, reference semantics):
// Reference type — shared across view models, mutations propagate unexpectedly
class User {
var id: String
var name: String
var email: String
var avatarURL: URL?
var preferences: UserPreferences
var orderHistory: [Order]
init(id: String, name: String, email: String, avatarURL: URL?,
preferences: UserPreferences, orderHistory: [Order]) {
self.id = id
self.name = name
self.email = email
self.avatarURL = avatarURL
self.preferences = preferences
self.orderHistory = orderHistory
}
}
// Bug: two view models share the same User reference
let user = User(id: "1", name: "Alice", email: "alice@example.com",
avatarURL: nil, preferences: .default, orderHistory: [])
let profileVM = ProfileViewModel(user: user)
let settingsVM = SettingsViewModel(user: user)
// Mutation in settingsVM silently affects profileVM
settingsVM.user.name = "Bob"
print(profileVM.user.name) // "Bob" — unexpected side effect!
// Not Equatable by default — SwiftUI can't diff efficiently
// Not Sendable — unsafe to pass across actor boundaries
// Not Codable by default — manual serialization neededCorrect (struct with let properties — value semantics, diffable, thread-safe):
// Value type — copies are independent, mutations are explicit
struct User: Equatable, Sendable, Codable {
let id: String
let name: String
let email: String
let avatarURL: URL?
let preferences: UserPreferences
let orderHistory: [Order]
}
// Each view model gets its own independent copy
let user = User(id: "1", name: "Alice", email: "alice@example.com",
avatarURL: nil, preferences: .default, orderHistory: [])
let profileVM = ProfileViewModel(user: user)
let settingsVM = SettingsViewModel(user: user)
// Mutation creates a new value — original is unchanged
let updatedUser = User(
id: user.id,
name: "Bob",
email: user.email,
avatarURL: user.avatarURL,
preferences: user.preferences,
orderHistory: user.orderHistory
)
settingsVM.updateUser(updatedUser)
print(profileVM.user.name) // "Alice" — unaffected, as expected
// Equatable: SwiftUI diffs efficiently — only re-renders when values change
// Sendable: safe to pass across actor boundaries (async/await, MainActor)
// Codable: automatic serialization for network/persistence
// For updates, use a builder pattern or functional copy
extension User {
func with(name: String? = nil, email: String? = nil) -> User {
User(
id: id,
name: name ?? self.name,
email: email ?? self.email,
avatarURL: avatarURL,
preferences: preferences,
orderHistory: orderHistory
)
}
}
// Clean, explicit mutations
let renamed = user.with(name: "Bob") // New value, original unchangedReference: Advanced iOS App Architecture (4th Ed.)
Views Never Access Repositories Directly
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 access data through ViewModels. ViewModels call Domain repository protocols. Views must not call ModelContext, @Query, repositories, or network clients directly.
Incorrect (view contains data access):
struct UserProfileView: View {
@Environment(\.modelContext) private var modelContext
let userID: UUID
var body: some View {
Text("Profile")
.task {
let descriptor = FetchDescriptor<UserEntity>(
predicate: #Predicate { $0.id == userID }
)
_ = try? modelContext.fetch(descriptor)
}
}
}Correct (View -> ViewModel -> Repository):
protocol UserRepository: Sendable {
func get(id: UUID) async throws -> User?
}
@Observable
final class UserProfileViewModel {
private let userRepository: any UserRepository
var user: User?
var isLoading = false
init(userRepository: any UserRepository) {
self.userRepository = userRepository
}
func load(id: UUID) async {
isLoading = true
defer { isLoading = false }
user = try? await userRepository.get(id: id)
}
}
struct UserProfileView: View {
@State var viewModel: UserProfileViewModel
let userID: UUID
var body: some View {
Group {
if let user = viewModel.user {
Text(user.name)
} else if viewModel.isLoading {
ProgressView()
}
}
.task { await viewModel.load(id: userID) }
}
}Repository Protocols in Domain, Implementations in Data 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.
Repository protocols are defined in the Domain layer — they describe WHAT data operations are available without specifying HOW they're performed. Concrete implementations (networking, CoreData, SwiftData, UserDefaults) live in the Data layer. This inversion means the Domain never depends on the Data layer, data sources can be swapped without touching business logic, and testing uses mock repositories.
Incorrect (repository class in Domain importing networking — domain depends on data layer):
// Domain/Repositories/UserRepository.swift
import Foundation // URLSession dependency in domain
// Concrete class in Domain — hardcodes networking implementation
class UserRepository {
private let baseURL = URL(string: "https://api.example.com")!
// Domain knows HOW data is fetched — violates dependency inversion
func fetchUser(id: String) async throws -> User {
let url = baseURL.appendingPathComponent("users/\(id)")
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.invalidResponse
}
return try JSONDecoder().decode(User.self, from: data)
}
// Cannot swap to local database without modifying domain code
func saveUser(_ user: User) async throws {
var request = URLRequest(url: baseURL.appendingPathComponent("users"))
request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(user)
let (_, _) = try await URLSession.shared.data(for: request)
}
}Correct (protocol in Domain, concrete implementations in Data layer — dependency inverted):
// Domain/Repositories/UserRepository.swift
// Protocol in Domain — describes WHAT, not HOW
// No imports — pure Swift
protocol UserRepository: Sendable {
func fetchUser(id: String) async throws -> User
func fetchAll() async throws -> [User]
func save(_ user: User) async throws
func delete(id: String) async throws
}
// Data/Repositories/RemoteUserRepository.swift
import Foundation // Framework imports only in Data layer
// Concrete implementation in Data layer — Domain doesn't know this exists
final class RemoteUserRepository: UserRepository {
private let networkClient: NetworkClient
private let baseURL: URL
init(networkClient: NetworkClient, baseURL: URL) {
self.networkClient = networkClient
self.baseURL = baseURL
}
func fetchUser(id: String) async throws -> User {
try await networkClient.get(
url: baseURL.appendingPathComponent("users/\(id)")
)
}
func fetchAll() async throws -> [User] {
try await networkClient.get(
url: baseURL.appendingPathComponent("users")
)
}
func save(_ user: User) async throws {
try await networkClient.post(
url: baseURL.appendingPathComponent("users"),
body: user
)
}
func delete(id: String) async throws {
try await networkClient.delete(
url: baseURL.appendingPathComponent("users/\(id)")
)
}
}
// Data/Repositories/SwiftDataUserRepository.swift
import SwiftData // Persistence framework only in Data layer
final class SwiftDataUserRepository: UserRepository {
private let modelContext: ModelContext
init(modelContext: ModelContext) {
self.modelContext = modelContext
}
func fetchUser(id: String) async throws -> User {
let descriptor = FetchDescriptor<UserEntity>(
predicate: #Predicate { $0.id == id }
)
guard let entity = try modelContext.fetch(descriptor).first else {
throw RepositoryError.notFound
}
return entity.toDomainModel()
}
func fetchAll() async throws -> [User] {
let descriptor = FetchDescriptor<UserEntity>(
sortBy: [SortDescriptor(\.name)]
)
return try modelContext.fetch(descriptor).map { $0.toDomainModel() }
}
// ... same interface, different implementation
func save(_ user: User) async throws { /* SwiftData save */ }
func delete(id: String) async throws { /* SwiftData delete */ }
}
// Testing — mock repository, no network or database required
final class MockUserRepository: UserRepository {
var users: [User] = []
var fetchUserCallCount = 0
func fetchUser(id: String) async throws -> User {
fetchUserCallCount += 1
guard let user = users.first(where: { $0.id == id }) else {
throw RepositoryError.notFound
}
return user
}
func fetchAll() async throws -> [User] { users }
func save(_ user: User) async throws { users.append(user) }
func delete(id: String) async throws { users.removeAll { $0.id == id } }
}Reference: Clean Architecture for SwiftUI
Do Not Add a Dedicated Use-Case 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.
In the clinic architecture, ViewModels call Domain repository protocols directly. Avoid adding a separate use-case/interactor layer unless a cross-feature workflow is truly shared and reused. The default flow is: View -> ViewModel -> Repository protocol.
Incorrect (extra layer with no reuse):
protocol FetchUsersUseCase {
func execute() async throws -> [User]
}
final class FetchUsersUseCaseImpl: FetchUsersUseCase {
private let userRepository: UserRepository
init(userRepository: UserRepository) {
self.userRepository = userRepository
}
func execute() async throws -> [User] {
try await userRepository.fetchAll()
}
}
@Observable
final class UserListViewModel {
private let fetchUsersUseCase: FetchUsersUseCase
var users: [User] = []
init(fetchUsersUseCase: FetchUsersUseCase) {
self.fetchUsersUseCase = fetchUsersUseCase
}
func loadUsers() async {
users = (try? await fetchUsersUseCase.execute()) ?? []
}
}Correct (ViewModel calls repository protocol directly):
protocol UserRepository: Sendable {
func fetchAll() async throws -> [User]
func delete(id: UUID) async throws
}
@Observable
final class UserListViewModel {
private let userRepository: any UserRepository
var users: [User] = []
init(userRepository: any UserRepository) {
self.userRepository = userRepository
}
func loadUsers() async {
users = (try? await userRepository.fetchAll()) ?? []
}
func delete(_ id: UUID) async {
do {
try await userRepository.delete(id: id)
users.removeAll { $0.id == id }
} catch {
// Route through ErrorRouting at feature level.
}
}
}Keep complex domain rules in Domain models and repository implementations, not in a default use-case layer.
Expose Display-Ready State Only From ViewModels
ViewModels transform domain models into display-ready properties. Views should NEVER receive raw domain models — they receive formatted strings, computed booleans, and pre-processed collections. This prevents business model changes from rippling into views and keeps formatting logic testable in the ViewModel.
Incorrect (ViewModel exposing raw domain models — view forced to format and transform):
@Observable
final class OrderDetailViewModel {
private let fetchOrderUseCase: FetchOrderUseCase
// Leaking domain model directly to view
var order: Order?
func loadOrder() async {
order = try? await fetchOrderUseCase.execute(orderId: orderId)
}
private let orderId: String
init(orderId: String, fetchOrderUseCase: FetchOrderUseCase) {
self.orderId = orderId
self.fetchOrderUseCase = fetchOrderUseCase
}
}
struct OrderDetailView: View {
@State var viewModel: OrderDetailViewModel
var body: some View {
if let order = viewModel.order {
VStack(alignment: .leading) {
// Formatting in view body — runs on every re-render
Text("Order #\(order.id.prefix(8).uppercased())")
.font(.headline)
// Date formatting in view — creates formatter every render
Text("Placed: \(order.createdAt.formatted(.dateTime.month(.wide).day().year()))")
// Business logic in view — price calculation
let subtotal = order.items.reduce(0) { $0 + $1.price * Double($1.quantity) }
let tax = subtotal * 0.08
let total = subtotal + tax
Text("Subtotal: $\(String(format: "%.2f", subtotal))")
Text("Tax: $\(String(format: "%.2f", tax))")
Text("Total: $\(String(format: "%.2f", total))")
.bold()
// Status formatting in view
HStack {
Circle()
.fill(order.status == .delivered ? .green :
order.status == .shipped ? .blue :
order.status == .processing ? .orange : .gray)
.frame(width: 8, height: 8)
Text(order.status.rawValue.capitalized)
}
}
}
}
}Correct (ViewModel exposes display-ready properties — view is a pure template):
@Observable
final class OrderDetailViewModel {
private let fetchOrderUseCase: FetchOrderUseCase
private let orderId: String
// Display-ready properties — pre-formatted, pre-computed
var orderNumber: String = ""
var placedDate: String = ""
var subtotalLabel: String = ""
var taxLabel: String = ""
var totalLabel: String = ""
var statusLabel: String = ""
var statusColor: StatusColor = .gray
var isLoading: Bool = false
var errorMessage: String?
enum StatusColor {
case green, blue, orange, gray
}
init(orderId: String, fetchOrderUseCase: FetchOrderUseCase) {
self.orderId = orderId
self.fetchOrderUseCase = fetchOrderUseCase
}
func loadOrder() async {
isLoading = true
defer { isLoading = false }
guard let order = try? await fetchOrderUseCase.execute(orderId: orderId) else {
errorMessage = "Failed to load order"
return
}
// All formatting happens ONCE in the ViewModel — testable without UI
orderNumber = "Order #\(order.id.prefix(8).uppercased())"
placedDate = "Placed: \(Self.dateFormatter.string(from: order.createdAt))"
let subtotal = order.items.reduce(0) { $0 + $1.price * Double($1.quantity) }
let tax = subtotal * 0.08
let total = subtotal + tax
subtotalLabel = Self.currencyFormatter.string(from: NSNumber(value: subtotal)) ?? ""
taxLabel = Self.currencyFormatter.string(from: NSNumber(value: tax)) ?? ""
totalLabel = Self.currencyFormatter.string(from: NSNumber(value: total)) ?? ""
statusLabel = order.status.rawValue.capitalized
statusColor = switch order.status {
case .delivered: .green
case .shipped: .blue
case .processing: .orange
default: .gray
}
}
// Formatters created once, reused — not recreated every render
private static let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .long
return f
}()
private static let currencyFormatter: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .currency
f.currencyCode = "USD"
return f
}()
}
// View is a pure template — only layout and property references
struct OrderDetailView: View {
@State var viewModel: OrderDetailViewModel
var body: some View {
VStack(alignment: .leading) {
Text(viewModel.orderNumber)
.font(.headline)
Text(viewModel.placedDate)
Text(viewModel.subtotalLabel)
Text(viewModel.taxLabel)
Text(viewModel.totalLabel)
.bold()
OrderStatusBadge(
label: viewModel.statusLabel,
color: viewModel.statusColor
)
}
.task {
await viewModel.loadOrder()
}
}
}
// Testing — verify formatting without any UI
@Test func orderFormattingIsCorrect() async {
let mockUseCase = MockFetchOrderUseCase(
stubbedOrder: .fixture(status: .delivered, total: 99.99)
)
let viewModel = OrderDetailViewModel(
orderId: "test",
fetchOrderUseCase: mockUseCase
)
await viewModel.loadOrder()
#expect(viewModel.statusLabel == "Delivered")
#expect(viewModel.statusColor == .green)
#expect(viewModel.totalLabel == "$107.99") // with tax
}Reference: Clean Architecture for SwiftUI
Ensure Constant View Count Per ForEach Element
When ForEach produces a variable number of views per element (via if/else with different view counts), SwiftUI cannot predict the total row count. It must instantiate ALL views upfront to gather identifiers, destroying lazy loading benefits. This means a LazyVStack with 10,000 items behaves like a regular VStack — all 10,000 views are created immediately. Ensure each ForEach iteration produces exactly 1 view (or a fixed number).
Incorrect (variable view count per element — breaks lazy loading):
ScrollView {
LazyVStack {
ForEach(items) { item in
// VARIABLE view count: sometimes 0 views, sometimes 1
// SwiftUI must build ALL items to count total views
if item.isVisible {
ItemRow(item: item)
}
// When isVisible is false, this iteration produces 0 views
// When isVisible is true, it produces 1 view
// SwiftUI can't predict the count without evaluating every element
}
}
}ScrollView {
LazyVStack {
ForEach(items) { item in
// VARIABLE view count: 1 view vs 2 views per iteration
ItemRow(item: item)
if item.hasSubtitle {
SubtitleRow(text: item.subtitle)
}
// Some iterations produce 1 view, others produce 2
}
}
}Correct (constant view count — exactly 1 view per element, lazy loading preserved):
ScrollView {
LazyVStack {
// Option 1: Filter in the model layer BEFORE ForEach
ForEach(viewModel.visibleItems) { item in
ItemRow(item: item)
// Always exactly 1 view per iteration
}
}
}ScrollView {
LazyVStack {
ForEach(items) { item in
// Option 2: Always produce 1 view, control visibility via opacity
ItemRow(item: item)
.opacity(item.isVisible ? 1 : 0)
.frame(height: item.isVisible ? nil : 0)
// Constant 1 view per iteration — SwiftUI can predict count
}
}
}ScrollView {
LazyVStack {
ForEach(items) { item in
// Option 3: Wrap conditional content in a single container
VStack(spacing: 0) {
ItemRow(item: item)
if item.hasSubtitle {
SubtitleRow(text: item.subtitle)
}
}
// Always exactly 1 VStack per iteration — constant view count
}
}
}Why this matters:
- Lazy stacks determine which items to render based on scroll position and predicted item count
- Variable view counts make prediction impossible — SwiftUI falls back to eager instantiation
- A 10,000-item lazy list becomes O(N) instead of O(visible) on initial load
Reference: WWDC23 — Demystify SwiftUI Performance
Filter and Sort in ViewModel, Never Inside ForEach
Filtering and sorting inside ForEach or the view body runs on EVERY body evaluation — even when the source data hasn't changed. A parent view state change that triggers re-evaluation will re-filter and re-sort the entire collection. Move these operations to the ViewModel where results can be cached and only recomputed when the source data actually changes.
Incorrect (inline filter/sort in body — runs on every re-evaluation):
struct TaskListView: View {
@State var viewModel: TaskListViewModel
var body: some View {
List {
// These run on EVERY body evaluation:
// - Parent view changes unrelated state → body re-evaluates → filter + sort runs
// - User scrolls → body re-evaluates → filter + sort runs
// - Any @Observable property changes → body re-evaluates → filter + sort runs
ForEach(
viewModel.tasks
.filter { $0.isActive && !$0.isArchived }
.sorted(by: { $0.dueDate < $1.dueDate })
.sorted(by: { $0.priority.rawValue > $1.priority.rawValue })
) { task in
TaskRow(task: task)
}
}
.searchable(text: $viewModel.searchQuery)
}
}
@Observable
class TaskListViewModel {
var tasks: [TaskItem] = []
var searchQuery: String = ""
// No caching — filtering logic lives in the view
}Correct (ViewModel caches filtered and sorted results):
struct TaskListView: View {
@State var viewModel: TaskListViewModel
var body: some View {
List {
// Pre-computed, cached — just iterates the result
ForEach(viewModel.activeTasks) { task in
TaskRow(task: task)
}
}
.searchable(text: $viewModel.searchQuery)
}
}
@Observable
class TaskListViewModel {
var tasks: [TaskItem] = [] {
didSet { recomputeActiveTasks() }
}
var searchQuery: String = "" {
didSet { recomputeActiveTasks() }
}
// Cached result — only recomputed when tasks or searchQuery changes
private(set) var activeTasks: [TaskItem] = []
private func recomputeActiveTasks() {
var result = tasks.filter { $0.isActive && !$0.isArchived }
if !searchQuery.isEmpty {
result = result.filter {
$0.title.localizedCaseInsensitiveContains(searchQuery)
}
}
result.sort { lhs, rhs in
if lhs.priority != rhs.priority {
return lhs.priority.rawValue > rhs.priority.rawValue
}
return lhs.dueDate < rhs.dueDate
}
activeTasks = result
}
func loadTasks() async {
tasks = try? await taskRepository.fetchAll() ?? []
}
}Key benefits:
- Filtering and sorting run only when source data or filter criteria change — not on every body evaluation
- View body is a simple
ForEachiteration — no computation - Easier to unit test: assert
viewModel.activeTasksdirectly without rendering views
Reference: WWDC23 — Demystify SwiftUI Performance
Provide Explicit id KeyPath — Never Rely on Implicit Identity
Always make models conform to Identifiable with a stable id property, or provide an explicit id: keyPath to ForEach. Never use \.self for non-trivial types (it uses the hash, which can collide) and never use array indices (which change on insert/delete). Stable identifiers ensure SwiftUI correctly tracks view state and animations across updates.
Incorrect (\.self identity — hash collisions cause state corruption):
struct TagListView: View {
let tags: [Tag]
var body: some View {
// \.self uses Hashable — if two Tags hash identically,
// SwiftUI treats them as the same view
ForEach(tags, id: \.self) { tag in
TagChip(tag: tag)
}
}
}
// \.self on String — duplicate strings are treated as the same view
ForEach(["Draft", "Review", "Draft"], id: \.self) { status in
// The two "Draft" entries are considered the SAME identity
// One will be silently dropped or share state with the other
StatusBadge(status: status)
}Incorrect (array index identity — state follows wrong items on insert/delete):
struct TaskListView: View {
@State var viewModel: TaskListViewModel
var body: some View {
List {
// Index-based identity: item at index 0 is always "identity 0"
ForEach(0..<viewModel.tasks.count, id: \.self) { index in
TaskRow(task: viewModel.tasks[index])
}
}
}
}
// Problem: user deletes item at index 2
// Before: [A(0), B(1), C(2), D(3)]
// After: [A(0), B(1), D(2)]
// SwiftUI thinks identity "2" is the same view — it shows C's state with D's data
// Animations: SwiftUI animates the LAST item as removed, not the deleted oneCorrect (Identifiable with stable database ID):
struct Task: Identifiable {
let id: UUID // stable — survives insert, delete, reorder
var title: String
var isComplete: Bool
}
struct TaskListView: View {
@State var viewModel: TaskListViewModel
var body: some View {
List {
// Identifiable — ForEach uses the stable UUID automatically
ForEach(viewModel.tasks) { task in
TaskRow(task: task)
}
// Insert at index 0: [New, A, B, C, D]
// SwiftUI correctly identifies New as a NEW view
// A, B, C, D retain their state and animate to new positions
}
}
}Correct (explicit id keyPath when Identifiable isn't appropriate):
struct ServerLog: Codable {
let timestamp: Date
let message: String
let traceId: String // stable unique identifier from server
}
struct LogListView: View {
let logs: [ServerLog]
var body: some View {
List {
// Explicit keyPath when the type doesn't conform to Identifiable
ForEach(logs, id: \.traceId) { log in
LogRow(log: log)
}
}
}
}Identity stability rules:
- Use database/server IDs (UUID, Int primary key) — they survive reordering and filtering
- Use
UUID()generated at creation time — not at render time (that creates new identity every render) - Avoid computed identities that change when data changes (e.g.,
id: \.titlebreaks if title is edited)
Reference: WWDC23 — Demystify SwiftUI Performance
Use LazyVStack/LazyHStack for Unbounded Content
VStack and HStack create ALL child views immediately, even those offscreen. For any list with more than ~20 items or dynamic content of unknown size, use LazyVStack/LazyHStack inside a ScrollView. Lazy stacks only create views as they scroll into the visible area, reducing memory usage and initial render time from O(N) to O(visible).
Incorrect (VStack with 500 items — all 500 views created immediately):
struct MessageListView: View {
@State var viewModel: MessageListViewModel
var body: some View {
ScrollView {
// VStack creates ALL 500 MessageRow views at once
// Even messages at the bottom that the user hasn't scrolled to yet
// Initial render: ~500 view allocations, ~500 body evaluations
VStack(spacing: 0) {
ForEach(viewModel.messages) { message in
MessageRow(message: message)
.padding(.horizontal)
}
}
}
}
}
// With 500 messages:
// - Memory: all 500 views allocated simultaneously
// - Render time: O(500) — body evaluates for every message
// - Visible on screen: approximately 10 messages
// - Wasted work: 490 views created but invisibleCorrect (LazyVStack — only visible views created):
struct MessageListView: View {
@State var viewModel: MessageListViewModel
var body: some View {
ScrollView {
// LazyVStack only creates views as they scroll into view
// Initial render: ~10-15 views (visible + small buffer)
LazyVStack(spacing: 0) {
ForEach(viewModel.messages) { message in
MessageRow(message: message)
.padding(.horizontal)
}
}
}
}
}
// With 500 messages:
// - Memory: ~10-15 views allocated (visible + buffer)
// - Render time: O(visible) — only visible message bodies evaluate
// - As user scrolls: new views created, offscreen views may be released
// - Initial render is nearly instant regardless of total countWhen NOT to use lazy stacks:
// Small, fixed-size lists (under ~20 items) — eager is cheaper than lazy overhead
struct SettingsView: View {
var body: some View {
ScrollView {
// 8 items — VStack is fine, lazy overhead isn't worth it
VStack(spacing: 12) {
SettingsRow(title: "Account")
SettingsRow(title: "Notifications")
SettingsRow(title: "Privacy")
SettingsRow(title: "Appearance")
SettingsRow(title: "Storage")
SettingsRow(title: "Language")
SettingsRow(title: "About")
SettingsRow(title: "Help")
}
.padding()
}
}
}Key differences:
LazyVStackdefers view creation until scroll position demands itLazyVStackdoes NOT cache views — scrolling back may recreate them (use@Statefor persistence)LazyVStackwithpinnedViews: [.sectionHeaders]supports sticky section headers
Reference: Apple Documentation — Creating performant scrollable stacks
Every Feature Has a Coordinator Owning NavigationStack
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.
Each feature module has a Coordinator (@Observable class) that owns a NavigationPath and manages all routing decisions. The coordinator is the ONLY object that can push/pop/present views. Views request navigation by calling coordinator methods — they never create NavigationLinks with destinations. This centralizes flow logic, enables deep linking, and makes navigation testable.
Incorrect (navigation logic scattered across views — untestable, no deep linking):
// Navigation decisions hardcoded in every view
// No central place to manage flow, test navigation, or handle deep links
struct OrderListView: View {
@State var viewModel: OrderListViewModel
var body: some View {
NavigationStack {
List(viewModel.orders) { order in
// Destination hardcoded in the view — cannot be tested or overridden
NavigationLink(destination: OrderDetailView(orderId: order.id)) {
Text(order.title)
}
}
.toolbar {
Button("Settings") {
// Another navigation target — scattered across views
}
}
}
}
}
struct OrderDetailView: View {
let orderId: String
var body: some View {
VStack {
// More hardcoded navigation — 10+ views each with their own links
NavigationLink(destination: TrackingView(orderId: orderId)) {
Text("Track Order")
}
NavigationLink(destination: RefundView(orderId: orderId)) {
Text("Request Refund")
}
}
}
}Correct (coordinator owns all routing — centralized, testable, deep-linkable):
// Route enum — every destination in one place
enum OrderRoute: Hashable {
case list
case detail(orderId: String)
case tracking(orderId: String)
case refund(orderId: String)
}
// Coordinator owns NavigationStack and all routing decisions
@Observable
final class OrderCoordinator {
var path = NavigationPath()
var presentedSheet: OrderSheetRoute?
func navigate(to route: OrderRoute) {
path.append(route)
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
func present(_ sheet: OrderSheetRoute) {
presentedSheet = sheet
}
// Deep link support — resolve URL to route
func handle(url: URL) -> Bool {
guard let route = OrderRoute(url: url) else { return false }
navigate(to: route)
return true
}
}
// Root view — NavigationStack bound to coordinator
struct OrderFlowView: View {
@State private var coordinator = OrderCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
OrderListView()
.navigationDestination(for: OrderRoute.self) { route in
// Coordinator controls which view maps to which route
// Views read coordinator from @Environment — injected below
switch route {
case .list:
OrderListView()
case .detail(let orderId):
OrderDetailView(orderId: orderId)
case .tracking(let orderId):
TrackingView(orderId: orderId)
case .refund(let orderId):
RefundView(orderId: orderId)
}
}
}
.sheet(item: $coordinator.presentedSheet) { sheet in
coordinator.sheetView(for: sheet)
}
.environment(coordinator)
}
}
// Views read coordinator from @Environment — no parameter drilling
// @Environment properties are excluded from @Equatable comparison
struct OrderDetailView: View {
let orderId: String
@Environment(OrderCoordinator.self) private var coordinator
var body: some View {
VStack {
Button("Track Order") {
coordinator.navigate(to: .tracking(orderId: orderId))
}
Button("Request Refund") {
coordinator.navigate(to: .refund(orderId: orderId))
}
}
}
}Reference: Advanced iOS App Architecture (4th Ed.)
Coordinators Must Support URL-Based Deep Linking
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.
Every coordinator must implement a method to resolve a URL into a Route. This enables push notification handling, universal links, Spotlight integration, and inter-feature navigation. The app-level coordinator dispatches URLs to feature coordinators, which push the appropriate route onto their NavigationStack.
Incorrect (deep link handling in SceneDelegate with manual view creation — fragile, duplicated):
// Deep link handling outside the navigation system
// Duplicates view creation logic, breaks when routes change
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let url = URLContexts.first?.url else { return }
// Manual URL parsing — no connection to the Route enum
if url.path.contains("order") {
let id = url.lastPathComponent
// Manually creating views — duplicates coordinator logic
let detailView = OrderDetailView(orderId: id)
// How to push this onto NavigationStack from here?
// Usually involves global state or NotificationCenter hacks
NotificationCenter.default.post(
name: .navigateToOrder,
object: nil,
userInfo: ["view": detailView] // This doesn't even work
)
}
}
}Correct (coordinators resolve URLs to typed routes — unified navigation path):
// Protocol — every feature coordinator supports deep linking
protocol DeepLinkable {
func handle(url: URL) -> Bool
}
// App-level coordinator dispatches to feature coordinators
@Observable
final class AppCoordinator: DeepLinkable {
var selectedTab: AppTab = .home
let orderCoordinator = OrderCoordinator()
let profileCoordinator = ProfileCoordinator()
func handle(url: URL) -> Bool {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return false
}
// Dispatch to the appropriate feature coordinator
switch components.host {
case "orders":
selectedTab = .orders
return orderCoordinator.handle(url: url)
case "profile":
selectedTab = .profile
return profileCoordinator.handle(url: url)
default:
return false
}
}
}
// Feature coordinator resolves URL to its Route enum
@Observable
final class OrderCoordinator: DeepLinkable {
var path = NavigationPath()
func handle(url: URL) -> Bool {
// Reuses the same Route enum used for in-app navigation
guard let route = OrderRoute(url: url) else { return false }
// Same navigate() method used everywhere
// Deep link and in-app navigation follow identical paths
navigate(to: route)
return true
}
func navigate(to route: OrderRoute) {
path.append(route)
}
}
// Register at app level — universal links, push notifications, Spotlight
@main
struct MyApp: App {
@State private var appCoordinator = AppCoordinator()
var body: some Scene {
WindowGroup {
AppTabView()
.environment(appCoordinator)
// Universal links
.onOpenURL { url in
_ = appCoordinator.handle(url: url)
}
// Push notification deep links
.onReceive(NotificationCenter.default.publisher(for: .deepLink)) { notification in
if let url = notification.userInfo?["url"] as? URL {
_ = appCoordinator.handle(url: url)
}
}
}
}
}Reference: Advanced iOS App Architecture (4th Ed.)
Present Modals Via Coordinator, Not Inline
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.
Modal presentations (sheet, fullScreenCover, alert, confirmationDialog) must be driven by coordinator-owned state, not inline view state. The coordinator exposes observable modal state, and the root NavigationStack view binds .sheet to it. This keeps modal logic testable and allows coordinators to present modals from anywhere in the flow.
Incorrect (inline modal state — untestable, scattered, cannot be triggered externally):
struct OrderDetailView: View {
let orderId: String
@State var viewModel: OrderDetailViewModel
// Modal state owned by the view — untestable
@State private var showRefundSheet = false
@State private var showCancelAlert = false
@State private var showContactSheet = false
var body: some View {
VStack {
Button("Request Refund") {
showRefundSheet = true // View controls presentation
}
Button("Cancel Order") {
showCancelAlert = true
}
Button("Contact Support") {
showContactSheet = true
}
}
// Sheets scattered throughout the view hierarchy
// Cannot be triggered from deep link, push notification, or coordinator
.sheet(isPresented: $showRefundSheet) {
RefundView(orderId: orderId)
}
.alert("Cancel Order?", isPresented: $showCancelAlert) {
Button("Cancel Order", role: .destructive) {
viewModel.cancelOrder()
}
}
.sheet(isPresented: $showContactSheet) {
ContactSupportView(orderId: orderId)
}
}
}Correct (coordinator owns modal state — testable, externally triggerable):
// Modal routes as an enum — same pattern as navigation routes
enum OrderSheetRoute: Identifiable {
case refund(orderId: String)
case contactSupport(orderId: String)
case shareOrder(orderId: String)
var id: String {
switch self {
case .refund(let id): return "refund-\(id)"
case .contactSupport(let id): return "contact-\(id)"
case .shareOrder(let id): return "share-\(id)"
}
}
}
enum OrderAlertRoute: Identifiable {
case cancelConfirmation(orderId: String)
case deleteConfirmation(orderId: String)
var id: String {
switch self {
case .cancelConfirmation(let id): return "cancel-\(id)"
case .deleteConfirmation(let id): return "delete-\(id)"
}
}
}
// Coordinator owns all modal state
@Observable
final class OrderCoordinator {
var path = NavigationPath()
var presentedSheet: OrderSheetRoute?
var presentedAlert: OrderAlertRoute?
func presentRefund(orderId: String) {
presentedSheet = .refund(orderId: orderId)
}
func presentCancelConfirmation(orderId: String) {
presentedAlert = .cancelConfirmation(orderId: orderId)
}
func dismissSheet() {
presentedSheet = nil
}
// Can be called from deep links, push notifications, etc.
@ViewBuilder
func sheetView(for route: OrderSheetRoute) -> some View {
switch route {
case .refund(let orderId):
RefundView(orderId: orderId)
case .contactSupport(let orderId):
ContactSupportView(orderId: orderId)
case .shareOrder(let orderId):
ShareOrderView(orderId: orderId)
}
}
}
// Root view binds modals to coordinator state
struct OrderFlowView: View {
@State private var coordinator = OrderCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
OrderListView()
.navigationDestination(for: OrderRoute.self) { route in
coordinator.destinationView(for: route)
}
}
// Sheets driven by coordinator — one binding point
.sheet(item: $coordinator.presentedSheet) { route in
coordinator.sheetView(for: route)
}
.environment(coordinator)
}
}
// Views request modals via coordinator — no @State booleans
struct OrderDetailView: View {
let orderId: String
@Environment(OrderCoordinator.self) private var coordinator
var body: some View {
VStack {
Button("Request Refund") {
coordinator.presentRefund(orderId: orderId)
}
Button("Cancel Order") {
coordinator.presentCancelConfirmation(orderId: orderId)
}
}
}
}Reference: Advanced iOS App Architecture (4th Ed.)
Never Use NavigationLink(destination:) — Use navigationDestination(for:)
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.
NavigationLink(destination:) is the legacy API that eagerly initializes the destination view and hardcodes the navigation target into the view itself. Use NavigationLink(value:) with .navigationDestination(for:) which lazily creates the destination and lets the coordinator control the mapping from route to view.
Incorrect (NavigationLink(destination:) — eager init, hardcoded destination, no coordinator):
struct OrderListView: View {
@State var viewModel: OrderListViewModel
var body: some View {
// Legacy NavigationView — also deprecated
NavigationView {
List(viewModel.orders) { order in
// destination: eagerly creates OrderDetailView for EVERY row
// Even rows off-screen have their DetailView initialized
NavigationLink(destination: OrderDetailView(
orderId: order.id,
// Hardcoded dependency — view decides its own destination
repository: OrderRepository(),
analytics: AnalyticsService.shared
)) {
OrderRowView(order: order)
}
}
}
}
}
// Problems:
// 1. OrderDetailView created for ALL 100 rows immediately (eager init)
// 2. View hardcodes its destination — cannot be overridden by coordinator
// 3. Dependencies injected at the call site — not through DI system
// 4. Deep linking impossible — no route to push programmatically
// 5. Navigation logic scattered across every viewCorrect (NavigationLink(value:) + .navigationDestination — lazy, coordinator-routed):
struct OrderListView: View {
@State var viewModel: OrderListViewModel
var body: some View {
List(viewModel.orders) { order in
// value: only pushes a Route enum value — no view creation
// The actual destination is resolved lazily by .navigationDestination
NavigationLink(value: OrderRoute.detail(orderId: order.id)) {
OrderRowView(
title: order.title,
subtitle: order.formattedDate
)
}
}
}
}
// Coordinator owns the NavigationStack and destination mapping
struct OrderFlowView: View {
@State private var coordinator = OrderCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
OrderListView()
// Destination resolved LAZILY — only when navigated to
// Coordinator controls the view factory
.navigationDestination(for: OrderRoute.self) { route in
switch route {
case .list:
OrderListView()
case .detail(let orderId):
// View created on-demand, not eagerly
// Dependencies injected through Environment, not call site
OrderDetailView(orderId: orderId)
case .tracking(let orderId):
TrackingView(orderId: orderId)
case .refund(let orderId, let reason):
RefundView(orderId: orderId, reason: reason)
}
}
}
.environment(coordinator)
}
}
// Benefits:
// 1. Zero eager initialization — views created only when navigated to
// 2. Coordinator controls all destination mapping in one place
// 3. Programmatic navigation: coordinator.navigate(to: .detail(orderId: "123"))
// 4. Deep linking works: coordinator.handle(url: deepLinkURL)
// 5. Testable: verify coordinator.path contains expected routesReference: Apple Documentation — NavigationStack
Define All Routes as a Hashable Enum
Every navigation destination in a feature must be defined as a case in a Hashable enum. This provides compile-time verification that all routes are handled, enables pattern matching for deep link resolution, and documents the feature's navigation graph in one place. Never use string-based routing.
Incorrect (stringly-typed navigation — no compile-time safety, easy to mistype):
// String-based routing — typos compile fine, crash at runtime
struct AppRouter {
func navigate(to screen: String, params: [String: Any]) {
switch screen {
case "orderDetail":
// Runtime cast — crashes if wrong type
let id = params["id"] as! String
showOrderDetail(id: id)
case "userProfile":
let userId = params["userId"] as! String
showProfile(userId: userId)
case "setttings": // Typo — compiles fine, never matches
showSettings()
default:
break // Silent failure for unknown routes
}
}
}
// Usage — no IDE autocomplete, no type safety
router.navigate(to: "orderDetial", params: ["id": 42])
// Typo in route name — compiles, silently fails at runtime
// Wrong param type (Int vs String) — compiles, crashes at runtimeCorrect (Hashable enum — compiler-verified, exhaustive, self-documenting):
// Every destination is a case with typed associated values
// Adding a new route forces handling in all switch statements
enum OrderRoute: Hashable {
case list
case detail(orderId: String)
case tracking(orderId: String)
case refund(orderId: String, reason: RefundReason?)
case review(orderId: String, rating: Int)
}
// Compiler verifies ALL cases are handled — missing one is a build error
extension OrderRoute {
// Deep link resolution via pattern matching
init?(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
components.host == "orders" else {
return nil
}
let pathComponents = components.path.split(separator: "/")
switch pathComponents.first.map(String.init) {
case nil:
self = .list
case "detail":
guard let id = pathComponents.dropFirst().first.map(String.init) else {
return nil
}
self = .detail(orderId: id)
case "tracking":
guard let id = pathComponents.dropFirst().first.map(String.init) else {
return nil
}
self = .tracking(orderId: id)
default:
return nil
}
}
}
// Usage — IDE autocomplete, type-safe parameters, compile-time verification
coordinator.navigate(to: .detail(orderId: "abc-123"))
coordinator.navigate(to: .refund(orderId: "abc-123", reason: .damaged))
// Adding a new case:
// case invoice(orderId: String)
// Immediately triggers "Switch must be exhaustive" errors
// in coordinator, deep link handler, and analytics — nothing is missedReference: Advanced iOS App Architecture (4th Ed.)
Related skills
FAQ
What does swift-ui-architect do?
swift-ui-architect: A skill for development. This provides functionality for development workflows.
When should I use swift-ui-architect?
When you need to use swift-ui-architect for development tasks, or when swift-ui-architect: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
swift-ui-architect.