Plain @Observable properties are just values when you read them — there's no $ projection. ObservableObject gave you $viewModel.username for free because the property wrapper itself produced a Binding; @Observable classes don't, so a child view that needs to write back into a model owned elsewhere has no obvious way to get one.
#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 have no
$projection by default; @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 more threading individual
Binding<String>parameters down through every layer just to edit one field - Works the same for objects pulled from @Environment, not just ones passed as parameters
#Gotcha
@Bindable does not own or create the object — it only unlocks bindings into an instance that already exists somewhere else. Writing @Bindable var viewModel = ProfileViewModel() as a default value inside a view without stable identity (a List row that gets recreated, for instance) produces a new instance on every recreation and breaks the binding's connection to whatever the rest of the app thinks is the current model. The instance needs to be owned upstream — with @State or @Environment — and handed down as a plain property that the child view marks @Bindable, not constructed inline where @Bindable itself is declared.