SwiftUI and Combine
🟪 Syntax · 🟦 Types/APIs · 🟩 Correct pattern · 🟥 Bug · 🟨 Gotcha · 🧠 Explanation
This sheet keeps the originalObservableObjectand Combine patterns. For new iOS 17+ SwiftUI code using Observation, typed navigation, and.task, see10-Modern-SwiftUI.md.
SwiftUI state ownership
| Tool | Who owns the value? | Use case |
|---|---|---|
@State | The view | Small local value state such as selection, toggle, or text |
@Binding | A parent or external owner | Two-way access to a value without ownership |
@StateObject | The view at a creation boundary | Create and retain an ObservableObject across view redraws |
@ObservedObject | Another owner | Observe an object injected into the view |
@EnvironmentObject | An ancestor/environment | Shared object required by a view subtree |
@Environment | SwiftUI environment | System or custom environment values |
Core rule:
Use@StateObjectwhere an observable reference is created and owned. Use@ObservedObjectwhere it is passed in. Use@Bindingwhen 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.
ObservableObjectis a reference-type observation contract.@Publishedis a property wrapper used inside an observable object. Its projected value,$property, is a publisher.- In this legacy Combine context, distinguish a SwiftUI
Bindingfrom a Combine publisher: it is not a publisher and does not own storage.
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.
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:
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
Cancellableis the protocol containingcancel().AnyCancellableis a type-erased concrete wrapper commonly stored by consumers.- A
Set<AnyCancellable>keeps subscriptions alive for the owner's lifetime.
private var cancellables = Set<AnyCancellable>()
publisher
.sink(receiveCompletion: { _ in }, receiveValue: { value in
print(value)
})
.store(in: &cancellables)Debounced search
$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:
owner -> cancellables -> subscription -> closure -> ownerUse [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:
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:
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 arrangementHStack: horizontal arrangementZStack: overlapping layersLazyVStack/LazyHStack: lazily created scroll contentLazyVGrid/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
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.
@StateObjectis used at the view model creation boundary.- Child views use
@ObservedObjector focused bindings. - Environment dependencies are reserved for genuinely shared app state.
