Swift & iOS

Understand SwiftUI state ownership and Combine-era observation, publishers, cancellation, and view composition.

~9 min reading
Suggest content

SwiftUI and Combine

🟪 Syntax · 🟦 Types/APIs · 🟩 Correct pattern · 🟥 Bug · 🟨 Gotcha · 🧠 Explanation
This sheet keeps the original ObservableObject and Combine patterns. For new iOS 17+ SwiftUI code using Observation, typed navigation, and .task, see 10-Modern-SwiftUI.md.

SwiftUI state ownership

ToolWho owns the value?Use case
@StateThe viewSmall local value state such as selection, toggle, or text
@BindingA parent or external ownerTwo-way access to a value without ownership
@StateObjectThe view at a creation boundaryCreate and retain an ObservableObject across view redraws
@ObservedObjectAnother ownerObserve an object injected into the view
@EnvironmentObjectAn ancestor/environmentShared object required by a view subtree
@EnvironmentSwiftUI environmentSystem or custom environment values

Core rule:

Use @StateObject where an observable reference is created and owned. Use @ObservedObject where it is passed in. Use @Binding when a child needs editable access to a value owned elsewhere.

An environment object is convenient for truly shared dependencies or session state, but excessive use hides dependencies and makes previews/tests harder.

Modern Observation (@Observable) can reduce wrapper ceremony on supported OS versions, but ownership rules remain important.

ObservableObject, @Published, and @Binding

For the complete modern @Binding explanation, declaration patterns, and comparisons, see State Management → @Binding.

  • ObservableObject is a reference-type observation contract.
  • @Published is a property wrapper used inside an observable object. Its projected value, $property, is a publisher.
  • In this legacy Combine context, distinguish a SwiftUI Binding from a Combine publisher: it is not a publisher and does not own storage.
SWIFT
final class SearchViewModel: ObservableObject {
    @Published var query = ""
    @Published private(set) var results: [SearchResult] = []
}
​
struct SearchField: View {
    @Binding var text: String
}

StateObject access from another view

Another view should not reach into a sibling or parent view to fetch its @StateObject. Instead, the owner passes the object as an @ObservedObject, passes a specific Binding, or injects a deliberately shared environment dependency.

SWIFT
struct ParentView: View {
    @StateObject private var model = ProfileViewModel()
​
    var body: some View {
        ChildView(model: model)
    }
}
​
struct ChildView: View {
    @ObservedObject var model: ProfileViewModel
}

Combine mental model

Think of Combine as a stream pipeline:

TEXT
Publisher -> operators -> subscriber -> AnyCancellable
  • Publisher: emits values and eventually finishes or fails.
  • Operator: transforms, filters, combines, delays, or switches streams.
  • Subscriber: receives values and completion.
  • AnyCancellable: represents the lifetime of the subscription. Releasing or cancelling it stops the subscription.

@Published is a convenient producer tied to a property. Publisher is the general protocol implemented by many streams.

Cancellable versus AnyCancellable

  • Cancellable is the protocol containing cancel().
  • AnyCancellable is a type-erased concrete wrapper commonly stored by consumers.
  • A Set<AnyCancellable> keeps subscriptions alive for the owner's lifetime.
SWIFT
private var cancellables = Set<AnyCancellable>()
​
publisher
    .sink(receiveCompletion: { _ in }, receiveValue: { value in
        print(value)
    })
    .store(in: &cancellables)
SWIFT
$query
    .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
    .removeDuplicates()
    .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
    .filter { !$0.isEmpty }
    .map { [service] query in service.searchPublisher(query: query) }
    .switchToLatest()
    .receive(on: DispatchQueue.main)
    .sink(
        receiveCompletion: { [weak self] completion in
            self?.handle(completion)
        },
        receiveValue: { [weak self] results in
            self?.results = results
        }
    )
    .store(in: &cancellables)

debounce waits for quiet time. removeDuplicates prevents identical searches. switchToLatest stops an older result stream from winning after a newer query has started.

Scheduling

  • subscribe(on:) influences where upstream subscription/work begins.
  • receive(on:) changes the scheduler used for downstream delivery.
  • UI state must be updated on the main actor/main thread.
  • Combine does not itself provide Swift Task priority semantics. GCD QoS and Swift task priority belong to their respective execution systems.

Combine memory leaks

A common cycle is:

TEXT
owner -> cancellables -> subscription -> closure -> owner

Use [weak self] when the subscription is stored by self and the closure does not need to keep self alive. Also cancel subscriptions when their work should end before object deallocation.

Do not use weak mechanically. A short-lived publisher may safely capture strongly if the intended lifetime is clear.

API-backed SwiftUI list

A production-ready list needs more than @Published var items:

SWIFT
enum LoadState<Value> {
    case idle
    case loading
    case loaded(Value)
    case empty
    case failed(Error)
}

The view model owns the state and repository dependency. The view switches over the state and renders progress, retry, empty, or content UI. Decode network DTOs outside the view model when a data layer exists.

ViewBuilder

@ViewBuilder allows a closure or computed property to return multiple/conditional views without manual type erasure.

Use it for container APIs or small conditional content slots:

SWIFT
struct Card<Content: View>: View {
    @ViewBuilder let content: () -> Content
​
    var body: some View {
        VStack(alignment: .leading, content: content)
            .padding()
    }
}

For substantial reusable UI, create a separate View type. Do not turn every computed subview into a ViewBuilder.

SwiftUI stacks

  • VStack: vertical arrangement
  • HStack: horizontal arrangement
  • ZStack: overlapping layers
  • LazyVStack / LazyHStack: lazily created scroll content
  • LazyVGrid / LazyHGrid: grid layouts

Choose based on layout behavior, not only visual direction. Lazy containers help large scroll content but have different sizing and identity behavior.

MVVM-C weather application structure

TEXT
App Coordinator
  -> Weather List View
  -> Weather List ViewModel
  -> Weather Repository
  -> Location Service
  -> Weather Details Coordinator/ViewModel
  • Coordinator owns the navigation flow.
  • View model exposes screen state and actions.
  • Repository hides API/caching details.
  • Location service hides Core Location.
  • @StateObject is used at the view model creation boundary.
  • Child views use @ObservedObject or focused bindings.
  • Environment dependencies are reserved for genuinely shared app state.