---
title: "SwiftUI Best Practices"
slug: "swiftui"
category: "ios"
tags: ["ios", "swiftui", "swift"]
status: "stable"
last_updated: 2026-08-14
summary: "View composition, the Observation macro, NavigationStack, previews, environment, and accessibility for SwiftUI on iOS 26."
related: ["[[coding/swift]]", "[[ios/mapkit]]", "[[ios/core-data]]"]
---

## Overview

SwiftUI on iOS 26 with Swift 6.2+ and Xcode 26 is the default UI stack for new iOS work. New Xcode 26+ projects default to `MainActor` isolation (Swift 6.2's approachable concurrency), so most view and view-model code needs no explicit `@MainActor` annotation; opt out with `nonisolated` for work that must run off the main actor. This page covers patterns that hold up at scale: small views, the Observation macro, type-erased navigation, preview-driven development, environment dependencies, and accessibility from the first commit. For language rules, see [[coding/swift]]; for map and persistence, see [[ios/mapkit]] and [[ios/core-data]].

## Keep views small and extract aggressively

A view's `body` should fit on one screen. When a modifier chain grows past 5 modifiers or the body past 30 lines, extract a subview.

```swift
struct ProfileHeader: View {
  let user: User
  var body: some View {
    HStack(spacing: 12) {
      AvatarView(url: user.avatarURL)
      VStack(alignment: .leading) {
        Text(user.name).font(.headline)
        Text(user.handle).font(.subheadline).foregroundStyle(.secondary)
      }
    }
    .padding(.horizontal)
  }
}
```

Small views preview faster and let the compiler infer types without the "too complex to type-check" error. Extract anything that takes its own props.

## Use `@Observable` for reference-type state

Drop `ObservableObject` and `@Published` on new code. The Observation macro (iOS 17+) tracks reads per property and only re-renders views that read a changed property.

```swift
import Observation

@Observable
final class FeedModel {
  var posts: [Post] = []
  var isLoading = false
  func load() async { /* ... */ }
}

struct FeedView: View {
  @State private var model = FeedModel()
  var body: some View {
    List(model.posts) { PostRow(post: $0) }
      .task { await model.load() }
  }
}
```

Use `@State` to own the model in the view that creates it. Use `@Bindable` in child views that need two-way bindings into an `@Observable` passed by parameter.

## Type-erase NavigationStack destinations with `Hashable`

Drive navigation by a typed path, not chained `NavigationLink` views. Map destination types to views once at the root.

```swift
struct AppRouter: View {
  @State private var path: [Route] = []
  var body: some View {
    NavigationStack(path: $path) {
      HomeView()
        .navigationDestination(for: Route.self) { route in
          switch route {
          case .post(let id): PostDetail(id: id)
          case .user(let id): UserDetail(id: id)
          }
        }
    }
  }
}

enum Route: Hashable { case post(Post.ID), user(User.ID) }
```

`path` is the single source of truth. Deep links and state restoration work by mutating the array.

## Drive previews with explicit state

Use the `#Preview` macro and pass stub data. A preview that does not exercise loading, empty, and error states is half a preview.

```swift
#Preview("Loaded") {
  FeedView(model: .preview(posts: Post.samples))
}

#Preview("Empty") {
  FeedView(model: .preview(posts: []))
}
```

Add a `static func preview(...)` on each model so previews and tests share the stub. Previews that talk to the network or disk are flaky; inline data is not.

## Put shared dependencies in the environment

Inject services through `@Environment` keys, not singletons. Tests and previews swap implementations by setting a different value on the root.

```swift
private struct AnalyticsKey: EnvironmentKey {
  static let defaultValue: any Analytics = NoopAnalytics()
}

extension EnvironmentValues {
  var analytics: any Analytics {
    get { self[AnalyticsKey.self] } set { self[AnalyticsKey.self] = newValue }
  }
}

// At the app root
RootView().environment(\.analytics, FirebaseAnalytics())
```

Use this for analytics, feature flags, the API client, and anything else a leaf view should not construct.

## Keep `body` declarative; compute elsewhere

ViewBuilder runs on every state change. Any work in `body` runs again on every render.

- Move sorting, filtering, and formatting into the model or a `@State` cache.
- Use `.task(id:)` for async work tied to a value; the closure cancels and restarts when the id changes.
- Reach for `.onChange(of:)` only when a side effect must fire on a value transition.

Profile with Instruments' SwiftUI template before guessing.

## Accessibility is not optional

Every interactive element needs a label. Every image that conveys information needs a description. Dynamic Type should not break the layout.

- `.accessibilityLabel("Save draft")` on icon-only buttons.
- `.accessibilityHint("Saves the current note without publishing.")` when the label is ambiguous.
- Test with the largest accessibility size via preview environment overrides; fix overflows with `ViewThatFits` or scrollable containers.
- Audit with the Accessibility Inspector before shipping.

## Related

- [[coding/swift]]
- [[ios/mapkit]]
- [[ios/core-data]]
