SwiftUI @Bindable vs @Binding: When to Use Each in iOS 26
@Binding forwards a value; @Bindable projects bindings from an @Observable class. Learn the exact rules, the compiler errors, and the migration path from @ObservedObject with runnable iOS 26 examples.
In SwiftUI, use @Binding when a child view needs a two-way handle to a value type (a Bool, String, struct field) owned by a parent, and use @Bindable when a child needs to derive bindings from an @Observable reference type (a class) that it doesn't own. Since iOS 17 both are load-bearing tools, and picking the wrong one is the single most common reason a form field silently stops updating. This guide walks the two macros side by side, with runnable examples and the compiler errors you'll actually hit. (I hit the class-vs-struct trap myself shipping a Settings screen last winter, and it cost me a whole afternoon of print-statement debugging before I noticed the missing macro.)
@Binding is for value types (structs, enums, primitives); @Bindable is for @Observable classes passed by reference.
@Bindable requires the class to be annotated with the @Observable macro from the Observation framework. It doesn't work with @ObservableObject.
You don't need @Bindable in a view that already declares the model with @State, @Environment, or @Query. Those wrappers already expose the $ syntax.
Use @Bindable inside a subview when the model was passed in as a plain property (let user: User) and you want $user.name bindings from it.
Minimum deployment target for @Bindable and @Observable is iOS 17, iPadOS 17, macOS 14, tvOS 17, watchOS 10, visionOS 1.
You can freely mix @Binding and @Bindable in the same view. They solve different problems, and one can't replace the other.
What @Binding does (and what it costs)
@Binding is a property wrapper that lets a child view read and write a value that lives somewhere else. It doesn't own storage; it holds a getter/setter pair, where get returns the current value and set propagates a mutation back to the source of truth. The compiler generates a projected value accessible with the $ prefix, and SwiftUI's built-in controls (TextField, Toggle, Slider, Picker) all take a Binding<T> as their first parameter.
The mental model that will save you time: @Binding is a pointer to a value that the parent guarantees will exist for the lifetime of the child. When the parent's @State mutates, the child re-renders. When the child mutates via the binding, the parent's state updates in place. Because value types are copied on assignment, without a binding there'd be no way for a subview to change something the parent stores.
struct ParentView: View {
@State private var isOn = false
var body: some View {
VStack {
Text(isOn ? "On" : "Off")
ToggleRow(isOn: $isOn) // pass a binding, not the value
}
}
}
struct ToggleRow: View {
@Binding var isOn: Bool
var body: some View {
Toggle("Enable notifications", isOn: $isOn)
}
}
Two important details. First, @Binding only makes sense for value types like Bool, Int, structs, or enums. If you try to use it for a class, the code compiles but the "two-way" behaviour becomes redundant, because classes are already reference types. Second, if you find yourself building a binding manually with Binding(get:set:) more than once in the same file, you almost always want a real state container instead.
What @Bindable does and why it exists
@Bindable is a property wrapper introduced alongside the Observation framework in iOS 17. Its only job is to produce bindings (via the $ projected value) from a class marked with the @Observable macro. It exists because @State stores values but doesn't synthesise bindings for arbitrary properties on an incoming class, and @Environment retrieves values but has the same limitation. Before iOS 17, this role belonged to @ObservedObject, which required a class conforming to ObservableObject with @Published properties.
Here's the minimum viable example. Notice that the parent uses @State to own the model, but the subview uses @Bindable to derive bindings from a model handed in from outside.
import SwiftUI
import Observation
@Observable
final class UserSettings {
var displayName: String = ""
var notificationsEnabled: Bool = true
var refreshInterval: Double = 30
}
struct SettingsScreen: View {
@State private var settings = UserSettings() // owns the model
var body: some View {
NavigationStack {
SettingsForm(settings: settings) // pass the reference, not a binding
}
}
}
struct SettingsForm: View {
@Bindable var settings: UserSettings // derive bindings from a passed-in reference
var body: some View {
Form {
TextField("Display name", text: $settings.displayName)
Toggle("Notifications", isOn: $settings.notificationsEnabled)
Slider(value: $settings.refreshInterval, in: 5...300)
}
}
}
Two things worth calling out. The parent passes settings, not $settings. You never wrap an @Observable in a Binding. And the subview declares its dependency with @Bindable, which is a signal both to the compiler (synthesise $) and to future readers (this view mutates the model). If SettingsForm only read from settings, you could drop @Bindable entirely and declare it as let settings: UserSettings.
What is the difference between @Bindable and @Binding in SwiftUI?
The clearest way to remember the difference is that @Binding forwards ownership of a value; @Bindable projects bindings from an object you do not own. They target different type categories, they compose differently in the view tree, and they generate different errors when misused.
Aspect
@Binding
@Bindable
Introduced
iOS 13 (2019)
iOS 17 (2023)
Works with
Value types (struct, enum, primitives)
@Observable reference types (classes)
Requires
Nothing on the target type
@Observable macro on the class
Passed as
$parentValue from a source of truth
The object itself, unwrapped
Projected value
Same binding, passed through
A binding to each stored property
Owns storage
No
No
Typical use
Reusable form controls
Subviews that mutate a passed-in model
Alternative if omitted
Hand-rolled Binding(get:set:)
Plain let or var property
An illustrative pairing: a screen that owns some ephemeral form state as a value and a persistent user model as a reference. Both wrappers appear in the same subview and each carries its half of the load.
@Observable final class Draft {
var title: String = ""
var body: String = ""
}
struct ComposeScreen: View {
@State private var draft = Draft()
@State private var isPresentingPreview = false
var body: some View {
ComposeForm(
draft: draft, // reference, no $
isPreviewing: $isPresentingPreview // binding to a Bool
)
}
}
struct ComposeForm: View {
@Bindable var draft: Draft
@Binding var isPreviewing: Bool
var body: some View {
Form {
TextField("Title", text: $draft.title)
TextEditor(text: $draft.body)
Toggle("Preview", isOn: $isPreviewing)
}
}
}
Try to swap the wrappers, and the compiler will refuse. @Binding var draft: Draft produces "Cannot convert value of type 'Draft' to expected argument type 'Binding<Draft>'" at the call site, because a class is not a binding. @Bindable var isPreviewing: Bool produces "Property wrapper cannot be applied to a non-observable type", because a Bool is not an @Observable. Honestly, the type system is doing most of the teaching here. Heed it.
When should I use @Bindable?
Reach for @Bindable whenever a view receives an @Observable class from a caller and needs to build bindings against its stored properties. That's the entire scope. In practice, this shows up in three recurring shapes.
1. Detail views editing a passed-in model
A list screen owns a collection; a detail screen edits one element. The list uses @State to own the array, and the detail uses @Bindable to project bindings from the element it was handed.
@Observable final class TodoItem: Identifiable {
let id = UUID()
var title: String = ""
var isDone: Bool = false
}
struct TodoDetail: View {
@Bindable var item: TodoItem // passed in from the list
var body: some View {
Form {
TextField("Title", text: $item.title)
Toggle("Done", isOn: $item.isDone)
}
}
}
2. Working with SwiftData models pulled from @Query
SwiftData's @Query returns model instances that are @Observable under the hood. To let a subview edit one, hand it the model as a plain parameter and declare it with @Bindable. Our SwiftData from zero to production guide walks through the full editing pipeline.
struct TripEditor: View {
@Bindable var trip: Trip // a @Model class from SwiftData
var body: some View {
Form {
TextField("Destination", text: $trip.destination)
DatePicker("Depart", selection: $trip.departDate)
}
}
}
3. Pulling an @Observable out of the environment
When you inject a model through .environment(_:), retrieving it with @Environment gives you the reference but not the projected bindings. Rebind it locally with @Bindable. The two-line dance below is one of the most common patterns in modern SwiftUI code:
struct ProfileEditor: View {
@Environment(UserSettings.self) private var settings
var body: some View {
@Bindable var settings = settings // shadow with @Bindable
Form {
TextField("Display name", text: $settings.displayName)
}
}
}
That local re-declaration is legal Swift and idiomatic SwiftUI. It costs nothing at runtime (@Bindable is a thin struct that stores a reference), and it unlocks the $ syntax for the scope of the view's body.
Do I need @Bindable with @Observable?
Not always. You only need @Bindable when a view accepts an @Observable as an incoming property and wants to project bindings from it. Views that own the model with @State, retrieve it as a raw @Environment value for read-only access, or receive it as a plain let for read-only display don't need the wrapper.
The rule of thumb: if you write $model.something anywhere in body, and model was not declared with @State, @Query, or another storage wrapper, add @Bindable. If you only read the model, like Text(model.name), leave it as a plain property.
// Owns the model — no @Bindable needed, @State already projects bindings.
struct Owner: View {
@State private var user = User()
var body: some View {
TextField("Name", text: $user.name) // works
}
}
// Read-only child. No @Bindable, no bindings, no ceremony.
struct Greeting: View {
let user: User
var body: some View {
Text("Hello, \(user.name)")
}
}
// Editable child. Needs @Bindable because it writes through the reference.
struct NameEditor: View {
@Bindable var user: User
var body: some View {
TextField("Name", text: $user.name)
}
}
SwiftUI is careful about invalidation here. Because User is @Observable, all three views re-render only when properties they actually touch change. The Observation framework's tracking works at the property level, not the object level, so a read-only Greeting that references user.name won't redraw when user.email changes.
Can I use @Binding with an @Observable class?
Technically yes, but you almost never should. Wrapping an @Observable class in a @Binding is legal Swift (the type Binding<UserSettings> compiles), but it defeats the point. Classes are already reference types, so mutations to settings.name propagate to every reader whether or not there's a binding in between. All the @Binding adds is the ability to reassign the entire reference from a child, which is a design smell in most cases.
// Legal, but almost always wrong.
struct BadEditor: View {
@Binding var settings: UserSettings // why?
var body: some View {
// If you only ever write to properties, the binding is dead weight.
TextField("Name", text: $settings.displayName)
// If you reassign, you replace the object the parent holds.
Button("Reset") { settings = UserSettings() }
}
}
// Preferred.
struct GoodEditor: View {
@Bindable var settings: UserSettings
var body: some View {
TextField("Name", text: $settings.displayName)
}
}
There's a narrow exception: a "type switcher" screen where the parent legitimately wants a child to swap out which instance is being edited. In that case, a @Binding var current: UserSettings makes the reassignment intent explicit. Everyone else (99% of screens, in my experience) should use @Bindable.
Migrating from @ObservedObject and @StateObject
If your project still uses the Combine-era ObservableObject protocol with @Published, the migration to @Observable and @Bindable is mechanical. The mapping is:
class Foo: ObservableObject { @Published var x = 0 } becomes @Observable class Foo { var x = 0 }. Drop the @Published annotations and the protocol conformance.
@StateObject private var vm = ViewModel() becomes @State private var vm = ViewModel(). @State now correctly owns reference types under Observation.
@ObservedObject var vm: ViewModel becomes @Bindable var vm: ViewModel. Same purpose, different mechanism.
@EnvironmentObject var vm: ViewModel becomes @Environment(ViewModel.self) var vm. Rebind locally with @Bindable when you need $.
The Combine framework isn't going away, but for view-model style state Apple's guidance since WWDC 2023 has been unambiguous: @Observable is the future, and @Published/@ObservedObject should be considered legacy for new code targeting iOS 17 and up. Performance improves noticeably too, because Observation tracks property reads instead of publishing every field, so views invalidate less often. Combine remains the right tool for event streams like network responses and gesture pipelines; pair it with async/await as covered in our structured concurrency patterns guide.
A side-by-side migration
// BEFORE — iOS 13 to 16
final class OldSettings: ObservableObject {
@Published var name: String = ""
}
struct OldForm: View {
@ObservedObject var settings: OldSettings
var body: some View {
TextField("Name", text: $settings.name)
}
}
// AFTER (iOS 17+)
@Observable
final class NewSettings {
var name: String = ""
}
struct NewForm: View {
@Bindable var settings: NewSettings
var body: some View {
TextField("Name", text: $settings.name)
}
}
Common pitfalls and compiler errors
Four failure modes account for almost every "why doesn't my binding work?" thread on the developer forums. I've hit three of them personally, so let me save you the head-scratching.
"Cannot convert value of type 'X' to expected argument type 'Binding<X>'"
You passed settings when the child declared @Binding var settings, or you passed $settings when the child expected a plain reference. The direction matters: $ when the child uses @Binding, nothing when the child uses @Bindable.
"Property wrapper cannot be applied to a non-observable type"
You annotated a struct, or a class-without-@Observable, with @Bindable. Add @Observable to the class definition. If it's a value type, switch to @Binding.
Silent no-op edits with @State on a class
Before iOS 17, using @State for a class instance was a footgun, because the wrapper didn't fire updates when class properties changed. Under the Observation framework, this is now correct. @State private var model = MyObservable() works. If a colleague still reaches for @StateObject, that reflex is now outdated.
Bindings breaking on view identity changes
If a parent recreates the @Observable instance on every render (say, UserSettings() in the middle of body), the child's @Bindable reference is fresh each time and any in-flight edits are lost. Instances belong in @State, .environment, or a stable dependency injector, not in the view body. This is analogous to the identity issues covered in our SwiftUI preview crash fix guide.
Frequently Asked Questions
Does @Bindable replace @ObservedObject?
Yes, for any class you can convert to @Observable. @Bindable plays the same role (projecting bindings from a passed-in reference) but requires the Observation framework instead of Combine's ObservableObject. Existing @ObservedObject code keeps working; new code targeting iOS 17+ should prefer @Bindable.
Do I need @State for @Observable classes?
Only in the view that owns the instance. @State tells SwiftUI to allocate and persist the object for the view's lifetime. Downstream views should receive the reference as a normal property and add @Bindable when they need to synthesise bindings.
Can I use @Bindable on a SwiftData @Model class?
Yes. @Model classes are @Observable under the hood, so they work with @Bindable exactly like any other observable. This is the standard pattern for edit screens over SwiftData objects returned from @Query.
What is the minimum iOS version for @Bindable?
iOS 17, iPadOS 17, macOS 14 (Sonoma), tvOS 17, watchOS 10, and visionOS 1. If you must support older systems, stay on @ObservedObject with ObservableObject, or gate the newer API behind #available checks.
Why does my @Bindable subview keep re-rendering?
Observation tracks which properties a view reads and only invalidates that view when those specific properties change. If your subview re-renders unexpectedly, add let _ = Self._printChanges() to inspect what changed. Usually the parent is passing a fresh instance rather than a stable one held in @State.
A practical guide to Apple's unified logging in Swift for iOS 26: how Logger replaces print, when to use each log level, how privacy annotations protect PII, and how OSLogStore and OSSignposter power in-app diagnostics and performance tracing.
SwiftUI's PhaseAnimator walks a view through ordered animation phases from a single trigger, replacing @State flags and asyncAfter chains. Learn per-phase curves, trigger patterns, and how to combine it with CustomAnimation, plus real-world code for like buttons and toasts.
Profile SwiftUI apps with Xcode Instruments in Xcode 26. A real-device workflow for finding hangs, frame drops, and memory leaks, plus custom signposts with OSSignposter.