@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…