Read a property on a plain @Observable class and you get a value. Nothing else. No $ projection.
ObservableObject handed you $viewModel.username for free, because the property wrapper itself produced the Binding. @Observable classes produce nothing of the kind. A child view that needs to write back into a model owned somewhere up the tree has nothing to grab.
#The old way
final class ProfileViewModel: ObservableObject {
@Published var username: String = ""
}
struct ProfileEditor: View {
@ObservedObject var viewModel: ProfileViewModel
var body: some View {
TextField("Username", text: $viewModel.username)
}
}
#The new way
import SwiftUI
@Observable
final class ProfileViewModel {
var username: String = ""
}
struct ProfileEditor: View {
@Bindable var viewModel: ProfileViewModel
var body: some View {
TextField("Username", text: $viewModel.username)
}
}
struct ProfileScreen: View {
@State private var viewModel = ProfileViewModel()
var body: some View {
ProfileEditor(viewModel: viewModel)
}
}
#Why it matters
- @Observable classes ship no
$projection. @Bindable adds one, scoped to the view that needs it - Ownership stays in one place, a single @State higher up, while children get full two-way bindings
- No threading individual
Binding<String>parameters down through every layer to edit one field - Same story for objects pulled from @Environment, not only ones passed as parameters
#Gotcha
@Bindable does not own or create the object. It opens bindings into an instance that already lives somewhere else.
Write @Bindable var viewModel = ProfileViewModel() as a default value inside a view without stable identity, a List row that gets recreated, and every recreation builds a fresh ProfileViewModel. The binding is now cut off from whatever the rest of the app thinks is the current model.
Own the instance upstream, with @State or @Environment. Hand it down as a plain property, and let the child view mark that property @Bindable. Never construct it inline where @Bindable is declared.