← 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.

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.

swiftuiios17bindings

Related shorts