iOS with SwiftUI · SwiftUI Foundations: Views, Layout, Lists and State
State and Data Flow: @State, @Binding and @Observable
Choose the right tool for each piece of state: @State for view-local values, @Binding for two-way access, @Observable models for shared state, and the environment for app-wide dependencies.
What you'll build
A checkout flow where a quantity stepper (a reusable child view) edits the parent's value, a shared cart model is used by several screens, and the cart badge re-renders only when the items change.
The idea
| Tool | Use it for |
|---|---|
@State |
simple values a view owns (a toggle, text being typed), or the model object a view creates |
@Binding |
giving a child view read-write access to a parent's value ($value) |
@Observable class |
shared, changing app data (a cart, a session); iOS 17 and later |
@Bindable |
creating bindings ($model.name) to properties of an @Observable object |
.environment(model) / @Environment(Model.self) |
passing a shared model deep into the hierarchy without threading it through every initializer |
The Observation framework (@Observable) tracks which properties each view's body actually reads. When a property changes, only views that read it re-render. This replaces the older ObservableObject/@Published pattern, which re-rendered every observing view on any change.
The code
@Observable
final class CartModel {
var items: [CartItem] = []
var couponCode = ""
var total: Double { items.reduce(0) { $0 + $1.price * Double($1.quantity) } }
func add(_ product: Product) { items.append(CartItem(product: product)) }
}
@main
struct ShopApp: App {
@State private var cart = CartModel() // the app owns the shared model
var body: some Scene {
WindowGroup {
RootView().environment(cart)
}
}
}
struct CartBadge: View {
@Environment(CartModel.self) private var cart
var body: some View {
Text("\(cart.items.count)") // re-renders only when items change
.font(.caption.bold())
}
}
struct QuantityStepper: View {
@Binding var quantity: Int // edits the parent's value
var body: some View {
Stepper("Quantity: \(quantity)", value: $quantity, in: 1...10)
}
}
struct CheckoutView: View {
@Environment(CartModel.self) private var cart
@State private var quantity = 1
var body: some View {
@Bindable var cart = cart // bindings to the model's properties
Form {
QuantityStepper(quantity: $quantity)
TextField("Coupon", text: $cart.couponCode)
LabeledContent("Total", value: cart.total, format: .currency(code: "EUR"))
}
}
}
Try the logic
Observation works outside SwiftUI too. withObservationTracking runs a "render", records which properties it read, and reports the first change to one of them, just as SwiftUI decides what to re-render:
import Observation
@Observable
final class CartModel {
var items: [String] = []
var couponCode = ""
}
let cart = CartModel()
@MainActor
func renderBadge(label: String) {
withObservationTracking {
print("\(label): badge shows \(cart.items.count)")
} onChange: {
print(" items changed -> the badge will re-render")
}
}
renderBadge(label: "first render")
cart.couponCode = "SAVE10" // the badge never read couponCode: nothing happens
print("coupon changed: no re-render")
cart.items.append("mug") // the badge read items
renderBadge(label: "second render")
Output
first render: badge shows 0 coupon changed: no re-render items changed -> the badge will re-render second render: badge shows 1
Run this on your computer — see “Get Started” for setup.
Common mistakes
@Statefor a model passed in from outside.@Stateinitialises once; a parent's new value is ignored. Pass plain values or an@Observablereference instead.- Duplicating state. Copying a model value into a child's
@Statecreates two sources of truth; use@Binding. - Missing
.environment(model). Reading@Environment(CartModel.self)without an ancestor providing it crashes at runtime. - Mixing
ObservableObjectand@Observablepatterns. New code on iOS 17+ should use@Observablethroughout.
Key points
@Statefor view-owned values,@Bindingfor two-way access to a parent's value.@Observablemodels hold shared data; SwiftUI re-renders only views that read the changed properties.- Share models with
.environment(...); create bindings to their properties with@Bindable. - Keep a single source of truth for every piece of data.
Further reading: Managing model data in your app, Migrating to the Observable macro.
Practice questions
What's the difference between @State and @Binding?
Answer
@State declares and owns a value inside a view; SwiftUI stores it and re-renders the view when it changes. @Binding doesn't own anything: it's a reference to a value owned elsewhere (passed with $value), letting a child view read and write the parent's state.
Why is @Observable more efficient than ObservableObject with @Published?
Answer
Observation tracks which properties each view's body reads and re-renders only those views when those properties change. With ObservableObject, any @Published change notified every view observing the object, even if it didn't use the changed property.
Open in the app to mark lessons complete, take notes and track your progress.