← All shorts
02iOS 17+2 min read

@Bindable: Two-Way Bindings Into @Observable

@Bindable unlocks two-way SwiftUI bindings into an @Observable class without giving up single ownership.

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.

swiftuiios17bindings

Related shorts