Migrating ObservableObject to @Observable
Goal
By the end of this tutorial, you will have migrated a small SwiftUI editing flow from ObservableObject to @Observable and @Bindable.
State it explicitly:
“Move a profile editor from Combine-style observation to the new Observation framework while keeping ownership obvious.”
Requirements
- Basic SwiftUI knowledge
- Xcode 15+
- iOS 17+
The use case
This migration shows up when you already have a screen that works, but the model wiring feels heavy. Maybe the parent owns the store, child views edit fields, and a few bindings get threaded through several layers.
Observation can simplify the code, but only if you keep the ownership boundary intact. That is what we will preserve here.
Step 1 - The simplest working version
Start with the legacy ObservableObject version so the migration has a baseline.
import SwiftUI
import Combine
final class ProfileStore: ObservableObject {
@Published var displayName = "Pascal"
@Published var email = "pascal@example.com"
}
struct ProfileScreen: View {
@StateObject private var store = ProfileStore()
var body: some View {
ProfileForm(store: store)
}
}
struct ProfileForm: View {
@ObservedObject var store: ProfileStore
var body: some View {
Form {
TextField("Display name", text: $store.displayName)
TextField("Email", text: $store.email)
}
}
}
What this already does correctly:
- the screen owns the store
- the child reads and edits the same model
- SwiftUI refreshes when
@Publishedchanges
Verification checkpoint: Run the screen and make sure typing in either field updates the form immediately.
Step 2 - Make it reusable
Now migrate the model to Observation and replace the old observation wrappers.
import SwiftUI
import Observation
@Observable
final class ProfileDraft {
var displayName = "Pascal"
var email = "pascal@example.com"
}
struct ProfileScreen: View {
@State private var draft = ProfileDraft()
var body: some View {
ProfileForm(draft: draft)
}
}
struct ProfileForm: View {
@Bindable var draft: ProfileDraft
var body: some View {
Form {
TextField("Display name", text: $draft.displayName)
TextField("Email", text: $draft.email)
}
}
}
What changed:
@ObservablereplacedObservableObject@Statenow owns the observable instance@Bindableexposes editing access to the child@Published,@StateObject, and@ObservedObjectare gone from this screen
The important part is not the wrapper swap.
It is the fact that ownership still lives in ProfileScreen.
Verification checkpoint: Type into both fields again and confirm the UI still updates without any extra glue code.
Step 3 - SwiftUI-specific refinement
Once the migration is working, clean up the surface area so the feature reads like SwiftUI instead of a model adapter.
struct ProfileForm: View {
@Bindable var draft: ProfileDraft
let onSave: () -> Void
var body: some View {
Form {
Section("Account") {
TextField("Display name", text: $draft.displayName)
TextField("Email", text: $draft.email)
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
}
Section {
Button("Save", action: onSave)
}
}
}
}
This refinement does two useful things.
First, it keeps mutation access focused on the editable fields.
Second, it separates intent (onSave) from data editing (@Bindable), which makes the view easier to reuse.
Quick check: The form should still edit live, but saving now happens through a single explicit action.
Step 4 - Extract the editable row
The last refinement is to split the form into a reusable row while keeping the same ownership model.
This is where @Bindable shines: the row can edit a field directly without becoming the owner.
struct ProfileFieldRow: View {
let title: String
@Binding var text: String
var body: some View {
HStack {
Text(title)
Spacer()
TextField(title, text: $text)
.multilineTextAlignment(.trailing)
}
}
}
And then use it from the form:
struct ProfileForm: View {
@Bindable var draft: ProfileDraft
let onSave: () -> Void
var body: some View {
Form {
Section("Account") {
ProfileFieldRow(title: "Display name", text: $draft.displayName)
ProfileFieldRow(title: "Email", text: $draft.email)
}
Section {
Button("Save", action: onSave)
}
}
}
}
What changed:
- the repeated field layout moved into a reusable row
- the parent still owns the draft
- the row still edits the same state through binding access
Verification checkpoint: Run the form and confirm both fields still update the same shared draft while the row code stays reusable.
Result
You now have a migration path that:
- removes Combine-era observation boilerplate
- keeps screen ownership explicit
- makes nested editors easier to extract
- preserves SwiftUI’s declarative data flow
That means you can migrate feature by feature instead of rewriting the whole app at once.
Common SwiftUI pitfalls
-
Mistake: Moving ownership into a child just because
@Bindableis convenient
Why it happens: the new API looks lightweight
How to fix it: keep the owning instance at the screen boundary and pass binding access down -
Mistake: Mixing
@StateObject,@ObservedObject, and@Observablein the same screen without a plan
Why it happens: incremental migration
How to fix it: migrate one ownership boundary at a time and verify the parent still owns the model -
Mistake: Using Observation to avoid designing a clear API
Why it happens: fewer wrappers feels like less architecture
How to fix it: keep read-only values, editable bindings, and actions distinct
When NOT to use this
Do not migrate purely for novelty. If a legacy screen is stable and the observation boundary is already clear, there may be no practical gain.
You should also avoid this pattern when the model lifecycle must be managed with explicit object ownership across long-lived asynchronous work. In those cases, a different ownership wrapper may still be the better fit.
Takeaway
If you remember one thing:
Migration to @Observable should simplify observation, not blur ownership.