---
name: swiftui-patterns
description: "SwiftUI architecture, state: @Observable, @State, @Bindable, @Environment, view splitting, ViewModifiers, .task, API migration, Writing Tools, Transferable, sharing. Use when structuring an app or owning state. Not for review (swiftui-pro) or visual design (hig-foundations)."
metadata:
  source: multi-agent-pipeline
---

# SwiftUI Patterns

How to shape a SwiftUI code base so views stay small, state has one clear
owner, and business logic lives outside `body`. Examples target iOS 26 and
Swift 6.3. Everything here also compiles for an iOS 17 deployment target unless
a line names a newer release.

Two neighbouring topics are handled elsewhere on purpose:

- Navigation (`NavigationStack`, `NavigationSplitView`, sheets, tabs, deep
  links): `swiftui-navigation`.
- Layout and controls (stacks, grids, lists, scroll views, forms, `.searchable`,
  overlays): `swiftui-layout-components`.

## Contents

1. [Model-View by Default](#1-model-view-by-default)
2. [Owning and Passing Observable State](#2-owning-and-passing-observable-state)
3. [Member Order Inside a View](#3-member-order-inside-a-view)
4. [Splitting Views Up](#4-splitting-views-up)
5. [Environment Values](#5-environment-values)
6. [Loading Data with .task](#6-loading-data-with-task)
7. [iOS 26 Additions](#7-ios-26-additions)
8. [Performance](#8-performance)
9. [Following the HIG](#9-following-the-hig)
10. [Writing Tools (iOS 18+)](#10-writing-tools-ios-18)
11. [Common Mistakes](#11-common-mistakes)
12. [Review Checklist](#12-review-checklist)
13. [References](#13-references)

## 1. Model-View by Default

A view describes what the screen looks like for a given state. Models and
services own the rules, the networking and the persistence. That split is the
Model-View (MV) approach, and it is the default here.

- Do not introduce a view model unless the project already uses them.
- Let SwiftUI coordinate the flow: `@State`, `@Environment`, `@Query`, `.task`
  and `.onChange` are the orchestration tools.
- Put services and shared models in the environment. Keep each view small and
  composable.
- A growing view gets split into subviews, not given a view model.
- Unit tests target models, services and business rules. Views stay declarative
  enough that they do not need their own tests.

```swift
struct RecipeListView: View {
    @Environment(RecipeService.self) private var service

    enum Phase {
        case fetching
        case failed(String)
        case ready([Recipe])
    }

    @State private var phase: Phase = .fetching

    var body: some View {
        List {
            switch phase {
            case .fetching:
                ProgressView()
            case .failed(let message):
                ContentUnavailableView("Recipes unavailable",
                                       systemImage: "exclamationmark.triangle",
                                       description: Text(message))
            case .ready(let recipes):
                ForEach(recipes) { RecipeRow(recipe: $0) }
            }
        }
        .task { await reload() }
        .refreshable { await reload() }
    }

    private func reload() async {
        do {
            phase = .ready(try await service.recipes())
        } catch {
            phase = .failed(error.localizedDescription)
        }
    }
}
```

Why MV, when a view model is still justified, and how to wire an app shell:
[references/architecture-patterns.md](references/architecture-patterns.md).

## 2. Owning and Passing Observable State

Mark every `@Observable` class that backs UI with `@MainActor`. Its properties
are read by views on the main actor, and Swift 6 strict concurrency rejects
unisolated mutation.

| Situation | Declaration |
|---|---|
| The view creates the object and controls its lifetime | `@State private var store = Store()` |
| The view gets an `@Observable` object and only reads it | `let store: Store` (no wrapper) |
| The view gets an `@Observable` object and needs `$` bindings | `@Bindable var store: Store` |
| The object is shared through the environment | `@Environment(Store.self) private var store` |
| Simple view-local value (flag, counter, field text) | `@State private var isExpanded = false` |
| Two-way link to a parent's `@State` or `@Bindable` property | `@Binding var isExpanded: Bool` |

`@State` for values is always `private`: nobody outside the view should set it.

```swift
@MainActor @Observable
final class PlaylistStore {
    var name = ""
    var tracks: [Track] = []
    var isSyncing = false
}

struct PlaylistScreen: View {
    @State private var store = PlaylistStore()

    var body: some View {
        VStack {
            PlaylistHeader(store: store)
            PlaylistNameField(store: store)
            PlaylistFooter()
        }
        .environment(store)
    }
}

struct PlaylistHeader: View {
    let store: PlaylistStore
    var body: some View { Text(store.name) }
}

struct PlaylistNameField: View {
    @Bindable var store: PlaylistStore
    var body: some View { TextField("Name", text: $store.name) }
}

struct PlaylistFooter: View {
    @Environment(PlaylistStore.self) private var store

    var body: some View {
        @Bindable var editable = store
        Toggle("Syncing", isOn: $editable.isSyncing)
    }
}
```

The last view shows the trick for environment objects: declare a local
`@Bindable` copy inside `body` to get `$` bindings.

Observation is tracked per property. `PlaylistHeader` reads only `name`, so a
change to `tracks` or `isSyncing` does not re-evaluate it. `ObservableObject`
invalidates every subscriber on any `@Published` change, so this alone removes
a large share of wasted updates.

### ObservableObject in older targets

Keep `ObservableObject` only for a deployment target of iOS 16 or earlier. When
moving to Observation:

| Before | After |
|---|---|
| `@StateObject` | `@State` |
| `@ObservedObject` | `let` (or `@Bindable` for bindings) |
| `@EnvironmentObject` | `@Environment(Type.self)` |

## 3. Member Order Inside a View

Keep the same top-to-bottom order in every view file:

1. `@Environment` properties
2. `let` properties
3. `@State` and other stored properties
4. computed `var`s
5. `init`
6. `body`
7. view builders and other helpers
8. `async` functions

## 4. Splitting Views Up

### Subview structs

Give each piece one job and pass it plain values and closures:

```swift
var body: some View {
    VStack(alignment: .leading) {
        TicketSummary(event: event, seat: seat)
        TicketBarcode(code: ticket.code)
        TicketActions(onTransfer: transfer, onRefund: refund)
    }
}
```

### Computed view properties

Pieces that only make sense in one file can stay as private computed
properties. Promote one to its own `View` struct once it is reused elsewhere or
needs its own state.

```swift
var body: some View {
    List {
        banner
        sortControls
        matches
    }
}

private var banner: some View {
    VStack(alignment: .leading) {
        Text(league.name).font(.title2)
        Text(league.season).font(.subheadline)
    }
}
```

### @ViewBuilder functions

Branching that does not justify a struct fits a private builder function:

```swift
@ViewBuilder
private func deliveryLabel(for state: DeliveryState) -> some View {
    switch state {
    case .delivered:
        Text("Delivered").foregroundStyle(.green)
    case .inTransit, .pending:
        Text("On the way").foregroundStyle(.secondary)
    }
}
```

### Custom view modifiers

When the same styling appears in several places, move it into a
`ViewModifier` and expose a `View` extension:

```swift
struct TileBackground: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(.background)
            .clipShape(.rect(cornerRadius: 12))
            .shadow(radius: 2)
    }
}

extension View {
    func tileBackground() -> some View { modifier(TileBackground()) }
}
```

### Keep the tree stable

Do not swap entire view hierarchies with an `if` at the top of `body`. Keep one
base view and move conditions into sections or modifier arguments, so SwiftUI
keeps identity and state. Once a view file passes roughly 300 lines, split it
into extensions separated by `// MARK: -`.

## 5. Environment Values

### Custom values with @Entry

Declare custom values and actions with the `@Entry` macro inside an
`EnvironmentValues` extension. The macro writes the key type for you.

```swift
extension EnvironmentValues {
    @Entry var palette: Palette = .standard
    @Entry var syncNow: @Sendable () async -> Void = {}
}

// Supplying values
InboxView()
    .environment(\.palette, .highContrast)
    .environment(\.syncNow) { await mailbox.sync() }

// Reading them
@Environment(\.palette) private var palette
@Environment(\.syncNow) private var syncNow
```

`@Entry` is a macro that ships with the Xcode 16 SDK and expands to an ordinary
`EnvironmentKey`, so it works for any deployment target, iOS 17 included. Write
a manual `EnvironmentKey` conformance only when the project still builds with
an older Xcode.

### Built-in values used most often

`\.dismiss`, `\.colorScheme`, `\.dynamicTypeSize`, `\.horizontalSizeClass`,
`\.isSearching`, `\.openURL`, `\.modelContext`.

## 6. Loading Data with .task

Start async work with `.task`. SwiftUI cancels it when the view goes away.

```swift
struct ForecastView: View {
    @State private var store = ForecastStore()

    var body: some View {
        List(store.days) { DayRow(day: $0) }
            .task { await store.load() }
            .refreshable { await store.reload() }
    }
}
```

`.task(id:)` cancels and restarts whenever the id changes:

```swift
.task(id: query) {
    guard !query.isEmpty else { return }
    await lookUp(query)
}
```

- Do not start an unmanaged `Task` in `onAppear` unless you store it and cancel
  it yourself.
- A `Task {}` inside a synchronous action, such as a button handler, is fine:
  update state right away, then await the slow part.

## 7. iOS 26 Additions

| API | What it does |
|---|---|
| `.scrollEdgeEffectStyle(.soft, for: .top)`, `.backgroundExtensionEffect()` | Liquid Glass companions; see `swiftui-liquid-glass` |
| `@Animatable` macro | Synthesises `animatableData` for a type; see `swiftui-animation` |
| `TextEditor(text:)` with an `AttributedString` binding | Rich text editing |

## 8. Performance

- Large collections use `LazyVStack`, `LazyHStack`, `LazyVGrid` or
  `LazyHGrid`. A plain stack creates every child immediately.
- `List` and `ForEach` data must be `Identifiable` with stable ids. Never use
  array positions as ids.
- Filter and sort in the model or in a computed property, not inline in
  `body`.
- A complex view that re-evaluates without need can conform to `Equatable` so
  SwiftUI can skip it when inputs match.

## 9. Following the HIG

This skill stops at structure and state. Visual rules live elsewhere:

- Type, color, contrast, spacing and safe areas: `hig-foundations`.
- Bars, tabs and toolbars: `hig-components-layout`.
- Empty, error and loading states, onboarding, permission screens:
  `hig-patterns`.
- Haptics and Core Haptics: `ios-haptics`.
- Liquid Glass: `swiftui-liquid-glass`.
- Theming, matched transitions, placeholders and form focus as screen
  recipes: `swiftui-ui-patterns`.

Two structural habits still belong here: leave `spacing:` off stacks unless a
value is deliberate, and keep text sizes and colors semantic so the view code
does not fight Dynamic Type or dark mode.

## 10. Writing Tools (iOS 18+)

`.writingToolsBehavior(_:)` decides how Apple Intelligence Writing Tools appear
on a text view.

| Value | Experience | Good for |
|---|---|---|
| `.automatic` | System decides from context (default) | Most views |
| `.complete` | Full inline proofread, rewrite and transform | Notes, mail, documents |
| `.limited` | Reduced experience in an overlay panel | Code editors, validated fields |
| `.disabled` | Writing Tools not offered | Passwords, search fields |

```swift
TextEditor(text: $journalEntry)
    .writingToolsBehavior(.complete)

TextField("Find", text: $filter)
    .writingToolsBehavior(.disabled)
```

In UIKit, `UITextView.isWritingToolsActive` is true while a session is running.
Hold off validation and undo grouping until it turns false.

Apple docs:
[WritingToolsBehavior](https://developer.apple.com/documentation/swiftui/writingtoolsbehavior),
[writingToolsBehavior(_:)](https://developer.apple.com/documentation/swiftui/view/writingtoolsbehavior(_:)).

## 11. Common Mistakes

| Mistake | Instead |
|---|---|
| Creating an object in `@ObservedObject` | `@State` (Observation) or `@StateObject` (legacy) |
| Expensive work in `body` | Model or computed property |
| `Task` started in `onAppear` | `.task`; an unstored `Task` is never cancelled |
| Array indices as `ForEach` ids | Stable `Identifiable` ids; indices break diffing |
| `$store.x` without `@Bindable` | Declare `@Bindable` for `$` on an `@Observable` |
| `@State` used for shared data | `@State` is view-local; share through an `@Observable` |
| One very long `body` | Extract subviews |
| `NavigationView` | `NavigationStack` |
| `foregroundColor(_:)` | `foregroundStyle(_:)` for semantic styles |
| Large closures inline in `body` | Private methods |
| `.sheet(isPresented:)` for a model value | `.sheet(item:)` |
| `AnyView` to mix types | `@ViewBuilder`, `Group` or generics; `AnyView` defeats identity and diffing ([migration notes](references/deprecated-migration.md#10-anyview-to-viewbuilder-and-concrete-types)) |
| `@AppStorage` inside an `@Observable` class | See below |
| A `spacing:` value on every stack | Omit unless intentional |

`@AppStorage` is a `DynamicProperty`. It only drives updates when it lives in a
`View`; inside an `@Observable` class, observation never sees its changes. Keep
it in the view, or use a stored property backed by `UserDefaults`:

```swift
@MainActor @Observable
final class ReaderPreferences {
    var fontName: String {
        didSet { UserDefaults.standard.set(fontName, forKey: "reader.font") }
    }

    init() {
        fontName = UserDefaults.standard.string(forKey: "reader.font") ?? "Georgia"
    }
}
```

## 12. Review Checklist

- [ ] Shared models use `@Observable` (iOS 17+), not `ObservableObject`
- [ ] `@State` owns objects; children receive them as `let` or `@Bindable`
- [ ] `NavigationStack` instead of `NavigationView`
- [ ] Async loading goes through `.task`
- [ ] Large collections use lazy stacks
- [ ] `ForEach` ids are stable `Identifiable` ids, not indices
- [ ] Views are split into focused subviews
- [ ] `body` does no heavy work
- [ ] Deeply shared state travels through the environment
- [ ] `foregroundStyle(_:)` where a semantic style fits better than a fixed color
- [ ] Repeated styling lives in a custom `ViewModifier`
- [ ] `.sheet(item:)` preferred to `.sheet(isPresented:)`
- [ ] Sheets own their actions and call `dismiss()` themselves
- [ ] MV followed; no view model without a reason
- [ ] `@Observable` view-facing classes are `@MainActor`
- [ ] Model types that cross concurrency boundaries are `Sendable`
- [ ] Stack `spacing:` omitted unless a specific value is needed

## 13. References

- [references/architecture-patterns.md](references/architecture-patterns.md):
  the case for MV, when a view model is justified, environment versus init
  injection, testing, app shell wiring, dependency modifier, `ModelContainer`,
  enum sheets, deep links, closure-based clients.
- [references/deprecated-migration.md](references/deprecated-migration.md):
  old API to current API, with the release each replacement needs.
- [references/platform-and-sharing.md](references/platform-and-sharing.md):
  `Transferable`, share and drag and drop, pasteboard, media viewer, top bar
  overlays, title menus, input bars, menu bar commands, macOS settings.
