swiftui · tutorial · architecture · state

Migrating ObservableObject to @Observable

Published July 6, 2026 · 5 min read · intermediate

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 @Published changes

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:

  • @Observable replaced ObservableObject
  • @State now owns the observable instance
  • @Bindable exposes editing access to the child
  • @Published, @StateObject, and @ObservedObject are 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 @Bindable is 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 @Observable in 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.

Next steps