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.

Create a free account to save this page, take notes and track your progress. Sign in

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:

What Observation tracks
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

  • @State for a model passed in from outside. @State initialises once; a parent's new value is ignored. Pass plain values or an @Observable reference instead.
  • Duplicating state. Copying a model value into a child's @State creates two sources of truth; use @Binding.
  • Missing .environment(model). Reading @Environment(CartModel.self) without an ancestor providing it crashes at runtime.
  • Mixing ObservableObject and @Observable patterns. New code on iOS 17+ should use @Observable throughout.

Key points

  • @State for view-owned values, @Binding for two-way access to a parent's value.
  • @Observable models 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.

Was this page helpful?