Why @Observable matters now
Before iOS 17, managing state observation in SwiftUI often meant wrestling with @ObservedObject, @StateObject, and ObservableObject protocol conformance. With @Observable, Apple simplified the entire pattern. Instead of boilerplate protocol implementations and @Published wrappers, you mark a class with a single macro and SwiftUI handles the rest. This practical guide for developers walks through @Observable in SwiftUI, showing how it streamlines state management for real production scenarios—from shopping carts to form handling.
Mark a class with @Observable
The foundation of @Observable is elegantly simple. Apply the macro to any class, and Swift automatically tracks which properties your views read. SwiftUI then re-renders only when those specific properties change.
import SwiftUI
import Observation
@Observable
final class CartModel {
var items: [Item] = []
var total: Decimal {
items.reduce(0) { $0 + $1.price }
}
}
struct Item: Identifiable {
let id = UUID()
let name: String
let price: Decimal
}Notice we don't conform to ObservableObject, declare @Published properties, or wrap anything in @escaping closures. The macro does the heavy lifting. Under the hood, @Observable instruments property access so SwiftUI knows exactly which properties each view depends on. This granular tracking is the key advantage over older patterns.
In a real e-commerce scenario, your cart model tracks items and computes totals. With @Observable, if a view only reads the total property, it won't refresh when you add an item—it refreshes when the total actually changes. That's fine-grained reactivity without the ceremony.
Own it with @State
When you need a model in a single view, use @State just as you would with any other type.
struct ShoppingCartView: View {
@State private var cart = CartModel()
var body: some View {
VStack {
List {
ForEach($cart.items) { $item in
HStack {
Text(item.name)
Spacer()
Text("$\(item.price)")
}
}
.onDelete { indices in
cart.items.remove(atOffsets: indices)
}
}
Divider()
HStack {
Text("Total:")
Spacer()
Text("$\(cart.total)")
.font(.headline)
}
.padding()
}
}
}Because CartModel is marked @Observable, SwiftUI automatically observes it. The view captures changes and re-renders accordingly. The @State property owns the model's lifetime—it persists for the view's lifecycle and resets when the view is destroyed.
For quick forms or transient state, this pattern is common in SaaS dashboards and travel booking apps. A user fills out a form, the @State model holds the data, and when they navigate away, the form resets. Clean and predictable.
Share with environment
Once a model exists in a parent view, inject it into the environment so child views can access it without prop drilling.
struct AppView: View {
@State private var cart = CartModel()
var body: some View {
NavigationStack {
VStack {
ShoppingCartView(cart: cart)
NavigationLink("Checkout") {
CheckoutView()
}
}
.environment(cart)
}
}
}
struct CheckoutView: View {
@Environment(CartModel.self) var cart
var body: some View {
VStack {
Text("Items in cart: \(cart.items.count)")
Text("Total: $\(cart.total)")
Button("Complete Order") {
// Process order with cart data
}
}
}
}The parent passes the model via .environment(cart), and child views retrieve it with @Environment. SwiftUI's environment is type-safe—you access CartModel.self directly, not a string key. This avoids the fragile environment key setup that older code required.
In ride-hailing or trading apps, a central user session or market data model lives in the root view, and tabs, modals, and nested screens all access it through the environment. No callbacks, no publishers, no observable wrappers. Just pass it down the tree.
Fewer unnecessary updates
One of the biggest wins with @Observable is performance. Views update only when properties they actually read change.
@Observable
final class UserProfile {
var name: String = "Alice"
var email: String = "alice@example.com"
var lastLogin: Date = Date()
}
struct UserNameDisplay: View {
@Environment(UserProfile.self) var profile
var body: some View {
Text(profile.name) // Only reads name
}
}
struct LastLoginDisplay: View {
@Environment(UserProfile.self) var profile
var body: some View {
Text("Last login: \(profile.lastLogin.formatted())") // Only reads lastLogin
}
}
@main
struct App: View {
@State private var profile = UserProfile()
var body: some View {
VStack {
UserNameDisplay()
LastLoginDisplay()
}
.environment(profile)
}
}When you call profile.email = "newemail@example.com", only views that read email refresh. UserNameDisplay doesn't update; it still displays "Alice". This selective invalidation is crucial for complex CRM interfaces or trading dashboards with dozens of reactive zones. The wrong view updating at the wrong time kills performance and creates subtle bugs.
Before @Observable, you'd manually manage which properties triggered which views. Now SwiftUI does it for you by tracking access. It's one fewer source of bugs.
Common mistakes
Forgetting to import Observation
The @Observable macro lives in the Observation framework, not SwiftUI. You must import it explicitly, or you'll get a "cannot find @Observable in scope" error.
import SwiftUI
import Observation // Don't forget this
@Observable
final class MyModel { }Mixing @State and @Environment carelessly
If a model is already in the environment, don't create a new @State copy in a child view. You'll end up with two separate instances, and changes in one won't reflect in the other. Use @Environment to access the shared instance.
// Wrong: creates a separate cart
struct CartBadgeView: View {
@State private var cart = CartModel() // DON'T do this if cart is in environment
var body: some View {
Text("\(cart.items.count)")
}
}
// Right: reads the shared cart
struct CartBadgeView: View {
@Environment(CartModel.self) var cart
var body: some View {
Text("\(cart.items.count)")
}
}Treating @Observable as a replacement for all state
@Observable works best for complex models with multiple properties or models shared across views. For simple boolean flags or single strings, @State with primitive types is still cleaner and more idiomatic.
// Fine: simple toggle
struct SettingsView: View {
@State private var isDarkMode = false
// ...
}
// Overkill: complex model for one property
@Observable
final class DarkModeModel {
var isDarkMode = false
}
struct SettingsView: View {
@State private var model = DarkModeModel()
// ...
}Key takeaways
@Observablereplaces the ceremony ofObservableObjectand@Published. Mark a class with the macro and SwiftUI tracks property access automatically.- Use
@State private var model = MyModel()to own a model in a single view, just like any other@Statevalue. - Pass models through the environment with
.environment(model)and retrieve them in child views with@Environment(MyModel.self). - Views update only when properties they read change. This fine-grained tracking eliminates unnecessary re-renders and keeps complex UIs responsive.
- Remember to import
Observation, avoid creating duplicate instances in child views, and use@Observablefor rich models, not trivial properties.






Comments
Loading comments…