iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

@Observable / @Environment

@Observable (Swift 5.9+) replaces @ObservableObject + @Published with a macro-based observation system. Each property is tracked individually, so SwiftUI re-renders only the views that read the property that changed. The migration is mechanical and the wins compound: less boilerplate, fewer accidental re-renders, simpler tests.

Observable models, scopes, and migration

EXAMPLE
import SwiftUI
import Observation

// 1) Declare an observable model
@Observable
final class Cart {
    var items: [String] = []
    var totalCents: Int = 0

    func add(sku: String, price: Int) {
        items.append(sku)
        totalCents += price
    }
}

// 2) Provide it via .environment(...) — no @StateObject ceremony
@main
struct ShopApp: App {
    @State private var cart = Cart()
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(cart)
        }
    }
}

// 3) Read it via @Environment(Cart.self)
struct CartBadge: View {
    @Environment(Cart.self) private var cart
    var body: some View {
        // Reads ONLY cart.items.count -> the view re-renders ONLY when count changes
        Text("Cart: \(cart.items.count)")
    }
}

// 4) Mutating from a button
struct AddButton: View {
    @Environment(Cart.self) private var cart
    var body: some View {
        Button("Add wool jacket") { cart.add(sku: "sku-1", price: 19_900) }
    }
}

// 5) Bind to a property (needs @Bindable inside the view)
struct EditableProfile: View {
    @Bindable var user: User           // declared as @Observable elsewhere
    var body: some View {
        TextField("Name", text: $user.name)
    }
}

@Observable final class User { var name: String = "" }

// 6) Migration from @ObservableObject — mechanical
//
// BEFORE
// final class Cart: ObservableObject {
//   @Published var items: [String] = []
//   @Published var totalCents: Int = 0
// }
// struct Badge: View {
//   @ObservedObject var cart: Cart      // or @StateObject for ownership
//   var body: some View { Text("\(cart.items.count)") }
// }
//
// AFTER
// @Observable final class Cart {
//   var items: [String] = []
//   var totalCents: Int = 0
// }
// struct Badge: View {
//   @Environment(Cart.self) var cart    // or @State for ownership
//   var body: some View { Text("\(cart.items.count)") }
// }

// 7) Fine-grained re-rendering — why it matters
// With @Published, ANY change to @Published x re-renders every subscriber.
// With @Observable, only views that READ that specific property re-render.
// This catches the 'why is my list re-rendering when only the badge changed?' bug.

// 8) Scoped state — derive a view-local model
struct CheckoutScreen: View {
    @State private var checkout = CheckoutModel()      // owned by this screen
    var body: some View {
        // Pass via .environment(checkout) to children that need it.
        ChildView().environment(checkout)
    }
}

@Observable final class CheckoutModel { var step: Int = 0 }
struct ChildView: View {
    @Environment(CheckoutModel.self) var checkout
    var body: some View { Text("Step \(checkout.step)") }
}

// 9) Tests — Observable models are plain reference types
// final class CartTests {
//   func test_add_updates_total() {
//     let c = Cart()
//     c.add(sku: "a", price: 100)
//     XCTAssertEqual(c.totalCents, 100)
//   }
// }
// No @MainActor magic, no environment context — they just work.

// 10) When @Observable is NOT the right fit
// - Background threads writing to UI-bound state -> use an actor or marshal to MainActor
// - Cross-process data -> use a real persistence layer + observation
// - Combine pipelines -> still supported; @Observable doesn't replace publishers

Why it matters

Migrate from @ObservableObject to @Observable as soon as your team is on Swift 5.9+. The mechanical change pays off twice: less boilerplate at the call site AND fewer unnecessary re-renders, because SwiftUI now only re-runs views that read the specific property that changed instead of every subscriber of the whole object.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
@Observable
class UserModel {
    var name = "Ada"
}

struct ProfileView: View {
    @Environment(UserModel.self) var model
    var body: some View { Text(model.name) }
}
Try it Yourself »

Discussion

Loading…