| name | swiftui-architecture |
| description | SwiftUI best practices and architecture expert - advises on state management, view composition, MVVM vs MV pattern, data flow, performance, navigation, and testing. Use when the user asks about SwiftUI architecture, state management (@State, @Binding, @Observable, @EnvironmentObject), view design, performance, or building scalable SwiftUI apps. |
SwiftUI Architecture & Best Practices Expert
Expert guidance on building maintainable, performant SwiftUI applications. Covers architecture patterns, state management, view composition, navigation, and testing.
Core Mental Model: Identity, Lifetime, and Dependencies
SwiftUI operates on three fundamental concepts (from WWDC21 "Demystify SwiftUI"):
- Identity: How SwiftUI recognizes elements as the same or distinct across updates
- Lifetime: How SwiftUI tracks the existence of views and data over time
- Dependencies: How SwiftUI knows when and what to update
View Identity → Controls Lifetime → Drives Dependency Updates
State (@State, @StateObject) is storage tied to a view's identity, not its value.
When identity changes, state is reset. When identity is stable, state persists.
Architecture Patterns
The MV Pattern (Apple's Recommended Approach)
For client/server apps, Apple's own samples (Fruta, FoodTruck) use a Model-View (MV) pattern where views act as their own view model, backed by observable store objects:
@MainActor
@Observable
class FoodTruckStore {
let httpClient: HTTPClient
private(set) var products: [Product] = []
private(set) var orders: [Order] = []
var premiumProducts: [Product] {
products.filter { $0.isPremium }
}
func loadAllProducts() async throws {
products = try await httpClient.load(Resource(url: Constants.Urls.products))
}
func saveProduct(_ product: Product) async throws {
}
}
struct ProductListScreen: View {
@Environment(FoodTruckStore.self) private var store
var body: some View {
List(store.products) { product in
ProductRowView(product: product)
}
.task { try? await store.loadAllProducts() }
}
}
Key principle: Avoid creating a new ObservableObject (new source of truth) just because you added a new view. The source of truth for a client/server app is the server.
MVVM — When It Makes Sense
Traditional MVVM (one ViewModel per view) is often overkill in SwiftUI and creates:
- Multiple competing sources of truth (each
ObservableObject is a new source of truth)
- Unnecessary boilerplate (20 screens → 20 ViewModels)
- Complex dependency injection chains
- Redundant synchronization code (SwiftUI already provides bindings)
Use MVVM when:
- A view has complex, independently testable business logic
- You need isolation from SwiftUI (e.g., shared logic with UIKit)
- The ViewModel maps between domain model and view-specific presentation model
Clean Architecture (3-Layer)
For large apps, apply Clean Architecture with three layers:
┌────────────────────────────────────────────┐
│ Presentation Layer │
│ SwiftUI Views + Local @State │
├────────────────────────────────────────────┤
│ Business Logic Layer │
│ Interactors / Stores + AppState │
├────────────────────────────────────────────┤
│ Data Access Layer │
│ Repositories / HTTP Clients / CoreData │
└────────────────────────────────────────────┘
AppState: Single ObservableObject / @Observable class holding global app state (auth, routing, user data). Knows nothing about business logic.
Interactor: Stateless, encapsulates business logic for a group of views. Reads/writes AppState or Bindings. Never returns data directly — pushes results to state.
Repository: Stateless gateway to a single data source (network API, CoreData, etc.). Hidden behind a protocol for testability.
Modular Architecture (Large Teams)
Divide the app by bounded context (domain-driven design):
App
├── CatalogModule/ ← CatalogStore + CatalogUI
├── OrderingModule/ ← OrderingStore + OrderingUI
├── ShippingModule/ ← ShippingStore + ShippingUI
└── FoundationCore/ ← Shared utilities, components, network layer
Each module can be an SPM package or folder. Teams work independently without interfering.
State Management
Property Wrapper Decision Guide
| Wrapper | Use When |
|---|
@State | Local, private view state (transient UI state) |
@Binding | Pass mutable state down to a child view |
@StateObject | Own the lifecycle of an ObservableObject in a view |
@ObservedObject | Reference an externally-owned ObservableObject |
@EnvironmentObject | Share an ObservableObject across a deep view hierarchy |
@Environment | Access environment values (colorScheme, locale, custom values) |
@Observable (iOS 17+) | Modern replacement for ObservableObject — simpler, more performant |
Modern State with @Observable (iOS 17+)
@Observable
class AppModel {
var username: String = ""
var isLoggedIn: Bool = false
var cart: [CartItem] = []
}
struct ProfileView: View {
@Environment(AppModel.self) private var model
@Bindable var model: AppModel
var body: some View {
TextField("Name", text: $model.username)
}
}
Source of Truth Hierarchy
Server / Database
↓
Store / AppState (@Observable / @StateObject)
↓
Screen Views (@EnvironmentObject / @Environment)
↓
Child Views (@Binding / props)
Never create "shortcut" sources of truth. If a child view needs to mutate parent state, pass a @Binding, not a copy.
View Composition & Design
Screens vs Views
Distinguish between screens (full pages) and reusable views (components):
| Screens | Views |
|---|
MovieDetailScreen | RatingStarsView |
LoginScreen | UserAvatarView |
HomeScreen | ProductRowView |
Screens are container views — they fetch data, hold state, and compose presentational views.
Presentational views receive data as parameters and have no external dependencies.
Container / Presenter Pattern
struct ProductListScreen: View {
@Environment(CatalogStore.self) private var store
var body: some View {
ProductListView(products: store.products)
.task { try? await store.loadAllProducts() }
}
}
struct ProductListView: View {
let products: [Product]
var body: some View {
List(products) { product in
ProductRowView(product: product)
}
}
}
Avoid AnyView — Use Generics or @ViewBuilder
func makeView(for breed: DogBreed) -> some View {
if breed == .labrador {
return AnyView(LabradorView())
}
return AnyView(PoodleView())
}
@ViewBuilder
func makeView(for breed: DogBreed) -> some View {
switch breed {
case .labrador: LabradorView()
case .poodle: PoodleView()
}
}
View Decomposition
Break large views into smaller components. Views are value types — they are cheap to create:
var body: some View {
VStack {
}
}
var body: some View {
VStack {
headerSection
ProductGrid(products: products)
FooterView()
}
}
private var headerSection: some View {
HStack { }
}
Identity & Performance
Stable Identifiers in ForEach
ForEach(items) { item in
ItemView(item: item)
.id(UUID())
}
ForEach(items.indices, id: \.self) { index in
ItemView(item: items[index])
}
struct Item: Identifiable {
let id: UUID
var name: String
}
ForEach(items) { item in
ItemView(item: item)
}
Prefer Single Conditional View (Inert Modifiers)
if isExpired {
TreatView(treat: treat).opacity(0.5)
} else {
TreatView(treat: treat)
}
TreatView(treat: treat)
.opacity(isExpired ? 0.5 : 1.0)
Minimize Dependency Scope
struct ProductDetailView: View {
@Environment(CatalogStore.self) private var store
let productId: UUID
var product: Product? { store.products.first { $0.id == productId } }
}
struct ProductDetailView: View {
let product: Product
}
@MainActor for UI-Touching Stores
@MainActor
@Observable
class CatalogStore {
var products: [Product] = []
func loadProducts() async throws {
products = try await httpClient.load(Resource(url: .products))
}
}
Navigation
NavigationStack (iOS 16+)
@Observable
class AppRouter {
var path = NavigationPath()
func navigate(to destination: AppRoute) {
path.append(destination)
}
func popToRoot() {
path.removeLast(path.count)
}
}
enum AppRoute: Hashable {
case productDetail(Product)
case orderHistory
case settings
}
struct RootView: View {
@State private var router = AppRouter()
var body: some View {
NavigationStack(path: $router.path) {
HomeScreen()
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .productDetail(let product): ProductDetailScreen(product: product)
case .orderHistory: OrderHistoryScreen()
case .settings: SettingsScreen()
}
}
}
.environment(router)
}
}
TabView with Navigation
@Observable
class TabRouter {
var selectedTab: Tab = .home
enum Tab: Hashable {
case home, catalog, orders, profile
}
}
struct MainTabView: View {
@State private var router = TabRouter()
var body: some View {
TabView(selection: $router.selectedTab) {
HomeScreen()
.tabItem { Label("Home", systemImage: "house") }
.tag(TabRouter.Tab.home)
CatalogScreen()
.tabItem { Label("Catalog", systemImage: "square.grid.2x2") }
.tag(TabRouter.Tab.catalog)
}
.environment(router)
}
}
Validation & Forms
Simple Forms: Computed Properties in View
struct LoginScreen: View {
@State private var username = ""
@State private var password = ""
private var isFormValid: Bool {
!username.trimmingCharacters(in: .whitespaces).isEmpty &&
password.count >= 8
}
var body: some View {
Form {
TextField("Username", text: $username)
SecureField("Password", text: $password)
Button("Login") { }
.disabled(!isFormValid)
}
}
}
Complex Forms: Extract to a Struct
struct LoginFormConfig {
var username: String = ""
var password: String = ""
var isFormValid: Bool {
!username.trimmingCharacters(in: .whitespaces).isEmpty &&
password.count >= 8
}
var usernameError: String? {
username.isEmpty ? "Username is required" : nil
}
}
struct LoginScreen: View {
@State private var form = LoginFormConfig()
var body: some View {
Form {
TextField("Username", text: $form.username)
if let error = form.usernameError {
Text(error).foregroundStyle(.red).font(.caption)
}
Button("Login") { }.disabled(!form.isFormValid)
}
}
}
Error Handling
@MainActor
@Observable
class CatalogStore {
var products: [Product] = []
var error: Error?
var isLoading = false
func loadProducts() async {
isLoading = true
error = nil
do {
products = try await httpClient.load(Resource(url: .products))
} catch {
self.error = error
}
isLoading = false
}
}
struct ProductListScreen: View {
@Environment(CatalogStore.self) private var store
var body: some View {
Group {
if store.isLoading {
ProgressView()
} else {
ProductListView(products: store.products)
}
}
.alert("Error", isPresented: .constant(store.error != )) {
() { { store.loadProducts() } }
} message: {
(store.error.localizedDescription )
}
.task { store.loadProducts() }
}
}
Grouping View Events with Enums
As child views grow in complexity, consolidate callbacks into an enum:
struct ReminderCellView: View {
let index: Int
let onChecked: (Int) -> Void
let onDelete: (Int) -> Void
let onEdit: (Int) -> Void
}
enum ReminderCellEvent {
case checked(Int)
case deleted(Int)
case edited(Int)
}
struct ReminderCellView: View {
let index: Int
let onEvent: (ReminderCellEvent) -> Void
var body: some View {
HStack {
Image(systemName: "square")
.onTapGesture { onEvent(.checked(index)) }
Text("Reminder \(index)")
Spacer()
Image(systemName: "trash")
.onTapGesture { onEvent(.deleted(index)) }
}
}
}
Testing Strategy
Pyramid Approach
E2E Tests (XCUITest)
/ slowest, highest confidence
/
Integration Tests
/ store + network mocks
/
Unit Tests
/ form validation, business logic structs
/
Xcode Previews
fastest feedback for view layout/logic
Unit Test Extracted Logic
struct ProductFilterForm {
var minPrice: Double?
var maxPrice: Double?
func filter(_ products: [Product]) -> [Product] {
guard let min = minPrice, let max = maxPrice else { return products }
return products.filter { $0.price >= min && $0.price <= max }
}
}
func test_filterByPrice_returnsCorrectProducts() {
let products = [
Product(id: 1, name: "Cheap", price: 10),
Product(id: 2, name: "Mid", price: 100),
Product(id: 3, name: "Expensive", price: 500),
]
let form = ProductFilterForm(minPrice: 50, maxPrice: 200)
let result = form.filter(products)
XCTAssertEqual(result.count, )
(result[].name, )
}
Mock Network Layer with Protocols
protocol HTTPClientProtocol {
func load<T: Decodable>(_ resource: Resource<T>) async throws -> T
}
struct HTTPClient: HTTPClientProtocol { }
struct HTTPClientStub: HTTPClientProtocol {
let response: Any
func load<T: Decodable>(_ resource: Resource<T>) async throws -> T {
response as! T
}
}
let store = CatalogStore(httpClient: HTTPClientStub(response: mockProducts))
Xcode Previews as Fast Feedback
#Preview("Product List - Loaded") {
ProductListScreen()
.environment(CatalogStore(httpClient: HTTPClientStub(response: Product.samples)))
}
#Preview("Product List - Empty") {
ProductListScreen()
.environment(CatalogStore(httpClient: HTTPClientStub(response: [])))
}
#Preview("Product List - Loading") {
ProductListScreen()
.environment(CatalogStore.loading)
}
Common Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|
AnyView everywhere | Erases type info, hurts performance & diagnostics | Use @ViewBuilder, generics, or Group |
| One ViewModel per View | Creates many competing sources of truth | Use a Store per bounded context |
Random id in ForEach | Forces full re-render, breaks animations | Use stable, persistent identifiers |
@ObservedObject for owned state | Object can be destroyed while view lives | Use @StateObject to own lifecycle |
Logic in body | Hard to test, slow preview compilation | Extract to computed properties or structs |
Unnecessary if/else branching | Different identity per branch → state reset | Prefer inert modifiers (.opacity, .hidden) |
Array indices as id | Unstable on insertion/deletion | Use Identifiable with persistent IDs |
Nested ObservableObject properties | SwiftUI doesn't observe nested object changes | Flatten state or use @Observable (iOS 17+) |
Deep @EnvironmentObject coupling in child views | Breaks reusability | Pass data as parameters to presentational views |
Decision Guide
Choosing an architecture:
- Small app (< 5 screens):
@State + @StateObject, no Store needed
- Medium client/server app: Single
@Observable Store, MV pattern
- Large app (multiple domains/teams): Multiple Stores per bounded context, consider Clean Architecture layers
Choosing state management:
- "This value is only used here" →
@State
- "A child needs to mutate this" →
@Binding
- "I own this object" →
@StateObject / @State var model = MyModel()
- "I reference this from a parent" →
@ObservedObject
- "This is needed deep in the hierarchy" →
@EnvironmentObject / @Environment
- "iOS 17+ and I want the simplest approach" →
@Observable + @Environment
Choosing navigation:
- iOS 16+:
NavigationStack with typed NavigationPath
- Deep linking needed: Store navigation path in a Router
@Observable
- Tab-based app:
TabView with selection bound to a Router
Choosing where to put logic:
- View-specific UI logic (simple): Computed property in view body
- View-specific UI logic (complex): Extract to a
struct (testable)
- Business/domain logic: Interactor or Store method
- Data fetching: Store method calling Repository/HTTPClient