Overview

SwiftUI on iOS 18 with Swift 6 and Xcode 16 is the default UI stack for new iOS work. 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 swift; for map and persistence, see mapkit and 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.

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.

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.

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.

#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.

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.