How to Migrate to @Observable Without Breaking Your App

So, you’ve finally convinced stakeholders to raise the deployment target from iOS 15 to iOS 17 – and the first thing on your mind is @Observable. You’ve read the docs, watched the WWDC sessions – it looks like a straightforward swap. Replace ObservableObject, drop the @Published wrappers, change @StateObject to @State. You ask Claude Code to analyze the codebase and plan the transition, accept it, and go out for coffee. When you’re back, the code is ready to run. Sweet!

The same picture - How To Migrate To @Observable Without Breaking Your App

Then the red flags start appearing. The dashboard screen that used to render instantly now lags when you switch tabs. Your smooth transaction list stutters during scroll. Xcode console floods with repeating network request logs, indicating that views are stuck in infinite redraw loops. To make matters worse, some views survived the transition quite well, while others broke spectacularly…

As obvious as it may sound: @Observable isn’t a syntactic upgrade – it’s a fundamentally different observation model. The old system broadcast: hey, something changed for the entire object. The new one tracks exactly which properties each view accesses and updates only when those specific properties change. It’s elegant and more efficient – in theory at least. In practice, it can induce endless redraw cycles and performance issues if misunderstood or used improperly…

This post explores the key differences between the two approaches, common migration pitfalls, and how to use @Observable effectively – as a better tool, not just a replacement. Let’s dive in!

Why @Observable Exists (And Why You Should Care)

SwiftUI’s state management has always been… quirky. If you’ve used SwiftUI from the beginning, you probably have a flowchart bookmarked somewhere explaining when to use @State vs @StateObject vs @ObservedObject vs @EnvironmentObject. You’ve most likely explained the difference between @StateObject and @ObservedObject to junior developers at least a couple of times. You’ve debugged views that stopped updating because someone used @ObservedObject where they should have used @StateObject, and the view model got recreated on every parent redraw.

Things got even worse when you wanted to make the view testable. The easiest way to do that was wrapping your view model with a protocol so you could swap in a mock for testing (or previews). With ObservableObject, you’d hit a wall immediately:

protocol ProfileViewModelProtocol: ObservableObject {
    var username: String { get set }
    var isLoading: Bool { get }
    func save()
}

// ❌ Doesn't compile
struct ProfileView: View {
    @ObservedObject var viewModel: any ProfileViewModelProtocol
    // Error: Type 'any ProfileViewModelProtocol' cannot conform to 'ObservableObject'
}

ObservableObject has an associated type (ObjectWillChangePublisher), which means you can’t use it as an existential. The only solution? Make your view generic:

// ✅ Compiles, but now you have a generic view
struct ProfileView<ViewModel: ProfileViewModelProtocol>: View {
    @ObservedObject var viewModel: ViewModel
    
    var body: some View { /* ... */ }
}

This looks innocent until you realize the infection spreads. Your ViewFactory now needs to handle the generic. Your coordinator becomes a mess of type constraints. Parent views that embed ProfileView either become generic themselves or resort to AnyView type erasure:

// Option A: Factory returns AnyView (lose type safety)
func makeProfileView() -> AnyView {
    AnyView(ProfileView(viewModel: RealProfileViewModel()))
}

// Option B: Factory becomes generic (complexity spreads)
func makeProfileView<VM: ProfileViewModelProtocol>(viewModel: VM) -> ProfileView<VM> {
    ProfileView(viewModel: viewModel)
}

I’ve worked in projects where an innocent request to make the view testable cascaded into refactoring half the navigation layer. All because ObservableObject couldn’t play nice with protocols.

Apple’s Observation framework, introduced in iOS 17, solves both problems: the property wrapper zoo and the generics nightmare.

First of all, the observation model changed. ObservableObject used Combine under the hood: any @Published property change fired objectWillChange, and every subscriber got notified regardless of which property they cared about. The @Observable uses compile-time macro magic to track property access per-view. If your view only reads username, it won’t redraw when isLoading changes. For brevity, I won’t dive into the technical details of how this macro works. Essentially, @Observable uses Swift macros to automatically generate property access tracking code at compile time. There’s an excellent series of videos from Point-Free where Brandon and Stephen explore the topic in depth if you are curious.

More importantly for us however, Observable is a marker protocol with no associated types. Which means existentials just work:

import Observation

protocol ProfileViewModelProtocol: Observable {
    var username: String { get set }
    var isLoading: Bool { get }
    func save()
}

// ✅ No generics needed
struct ProfileView: View {
    var viewModel: any ProfileViewModelProtocol
    
    var body: some View { /* ... */ }
}

No generic views. No type erasure. No cascading refactors. Your view takes a protocol, your factory returns a concrete type, and everyone’s happy.

This alone makes @Observable worth the migration. That is, assuming you do it correctly. Which, as we’re about to see, is harder than it looks…

The Find & Replace Migration (And Why It Almost Works)

Armed with your newfound understanding of @Observable, you’re ready to migrate. The mapping seems obvious:

  • ObservableObject@Observable
  • @Published → just delete it
  • @StateObject@State
  • @ObservedObject → …remove it?

You open your first view model, make the changes, and it works. The second view model also works. So is the third. Finally, you’re confident enough to let the AI take over. The app compiles and the tests pass (or are quickly fixed by the AI). Can’t be that easy, can it? But everything seems to work fine… Finally, you push the changes and make a PR. Cautious about merging it just yet, you build a smoke test app from your branch and ask a friendly QA to take a look before you press the big, green button.

And it’s good that you did. A day later the QA lets you know that the profile screen sometimes doesn’t update. You can’t reproduce it at first. Then you notice: changing the username in the edit sheet doesn’t reflect on the profile screen until you force a refresh. But the code looks fine. The view model is @Observable. The view accesses the property. What’s going on?

Here’s the old code:

// Old: ObservableObject
final class ProfileViewModel: ObservableObject {
    @Published var username: String = ""
    @Published var bio: String = ""
}

struct ProfileView: View {
    @ObservedObject var viewModel: ProfileViewModel

    var body: some View {
        VStack {
            Text(viewModel.username)
            Text(viewModel.bio)
        }
    }
}

And here’s the “migrated” version:

// Migrated: @Observable
@Observable
final class ProfileViewModel {
    var username: String = ""
    var bio: String = ""
}

struct ProfileView: View {
    let viewModel: ProfileViewModel  // Just removed @ObservedObject, right?

    var body: some View {
        VStack {
            Text(viewModel.username)
            Text(viewModel.bio)
        }
    }
}

This compiles. It even runs. And if the parent view creates the ProfileViewModel inline, it might even appear to work:

// This works (by accident)
struct ParentView: View {
    var body: some View {
        ProfileView(viewModel: ProfileViewModel())
    }
}

But the moment your view model comes from somewhere else – a coordinator, a parent view’s state, a factory – observation silently breaks:

// ❌ FAILURE CASE 1: Factory creates fresh view model each time
class Coordinator {
    func makeProfileView() -> ProfileView {
        // Fresh instance every call -- no persistence!
        ProfileView(viewModel: ProfileViewModel())
    }
}
// Each time SwiftUI rebuilds the view hierarchy, 
// the coordinator hands out a brand new view model.
// User's changes? Gone.

// ❌ FAILURE CASE 2: Child creates view model without @State
struct ProfileView: View {
    let viewModel = ProfileViewModel()  // Recreated on EVERY parent redraw!
    
    var body: some View {
        Text(viewModel.username)  // Always shows default value
    }
}

// ✅ WORKS: Parent owns with @State, child receives
struct ParentView: View {
    @State private var viewModel = ProfileViewModel()  // Preserved
    
    var body: some View {
        ProfileView(viewModel: viewModel)  // This actually works!
    }
}

struct ProfileView: View {
    var viewModel: ProfileViewModel  // Plain property is fine here
    
    var body: some View {
        Text(viewModel.username)  // Will update when username changes
    }
}

The view receives the view model, and if everything is set up correctly, observation works automatically – no property wrapper needed on the child. But here’s the trap: this only works if the view model instance persists between renders. If something upstream recreates the view model on each render cycle, you’re observing a fresh instance every time. And the old one (with your changes) is gone.

The infuriating part? This bug is completely silent. No warnings, no errors, no console spam. The view just doesn’t update.

But wait! There’s another trap waiting for you: nested observable objects. This was already painful with ObservableObject, but at least it failed loudly (your view simply wouldn’t update and you’d know something was wrong). With @Observable, it fails in more creative ways.

Consider this setup:

@Observable
class UserSettings {
    var theme: String = "light"
    var fontSize: Int = 14
}

@Observable
class UserSession {
    var currentUser: String = ""
    var settings: UserSettings = UserSettings()
}

And a view that displays the theme:

struct SettingsView: View {
    var session: UserSession

    var body: some View {
        Text("Current theme: \(session.settings.theme)")
    }
}

Now you tap a button somewhere that does session.settings.theme = "dark". Does the view update?

Yes – but only because both UserSession and UserSettings are marked @Observable. The observation framework correctly follows the property access chain through nested observable objects. But mix Observable with a plain class, and you’re back to silent failures.

The key point isn’t that nested objects don’t work – they do, when set up correctly. The takeaway is that @Observable requires you to be intentional about your object graph in ways ObservableObject didn’t. You can’t just slap the macro on a class and assume everything downstream will magically become observed.

The @StateObject Lifecycle Trap

So far, we’ve covered views that receive view models from elsewhere. But what about views that create their own models? Unfortunately, an even more insidious bug awaits if you migrate such views to use @Observable view models. This one doesn’t just cause missed updates. It tanks performance, triggers duplicate network calls, and can lock your UI in an infinite redraw loop.

Let’s take a look at child view that owns its view model:

// Old: ObservableObject
struct TransactionListView: View {
    @StateObject var viewModel = TransactionListViewModel()

    var body: some View {
        List(viewModel.transactions) { transaction in
            TransactionRow(transaction: transaction)
        }
        .onAppear { viewModel.loadTransactions() }
    }
}

As you surely know, @StateObject tells SwiftUI to create the view model once and preserve it across parent view redraws. Without it, every time the parent view is regenerated, TransactionListView would get a fresh TransactionListViewModel instance and call loadTransactions() again.

Now watch what happens with a naive migration:

// ❌ Broken migration
struct TransactionListView: View {
    let viewModel = TransactionListViewModel()  // Recreated on every parent redraw!

    var body: some View {
        List(viewModel.transactions) { transaction in
            TransactionRow(transaction: transaction)
        }
        .onAppear { viewModel.loadTransactions() }
    }
}

No compiler warning. No runtime error. Just a view model that gets destroyed and recreated on every render. Each time, a fresh call to loadTransactions() hits your API. If the view model does heavy computation with the fetched data, your UI stutters. If it holds the state the user was editing, it vanishes mid-keystroke.

Moreover, if the child view’s state changes after its view model fetches data (which is the whole point), the parent view might need to redraw – creating a fresh instance of the child view’s view model and perpetuating an endless loop.

In the case of my migration, this was the exact source of all the views stuck in redraw loops. Parent redraws child → child creates new view model → new view model triggers state change → parent redraws again → repeat forever.

So, what can we do about it? Fortunately, we have a couple of options at our disposal.

We can use @State, which is a direct equivalent of @StateObject in Observable environment:

struct TransactionListView: View {
    @State var viewModel = TransactionListViewModel()

    var body: some View {
        List(viewModel.transactions) { transaction in
            TransactionRow(transaction: transaction)
        }
        .onAppear { viewModel.loadTransactions() }
    }
}

It’s the simplest solution, but also the most limiting. If you go this route, you’ll effectively give up on view testability and tightly couple a particular view with a particular view model. I wouldn’t recommend this option for anything beyond the simplest, read-only views.

Alternatively, we can ask parent view model to provide a child view model for the child view:

@Observable
class DashboardViewModel {
    var transactionListVM = TransactionListViewModel()
    var accountSummaryVM = AccountSummaryViewModel()
}

struct DashboardView: View {
    @State var viewModel = DashboardViewModel()

    var body: some View {
        VStack {
            TransactionListView(viewModel: viewModel.transactionListVM)
            AccountSummaryView(viewModel: viewModel.accountSummaryVM)
        }
    }
}

This is a much better option. Since the view model is now injectable, the child view becomes testable. If you abstract the view model with a protocol, you can break the coupling between the view and its implementation, making the view reusable. The only challenge is deciding which component would be responsible for creating these child view models. In the example above, it’s the parent view model, but injecting a factory or using a Dependency Injection tool is also an interesting alternative.

The key thing to remember: @StateObject isn’t just a property wrapper – it’s a complete lifecycle manager. When you remove it, you need to consciously decide who owns the view model’s lifecycle now.

Common Migration Patterns

We’ve covered enough failures and bugs. Now let’s look at the most common @Observable migration patterns that actually work.

As we’ve discussed, the simplest case is letting the view own its view model. To do that, replace @StateObject with @State:

// Before
@StateObject private var viewModel = ProfileViewModel()

// After
@State private var viewModel = ProfileViewModel()

@State handles view model lifecycle the same way @StateObject did – create once, preserve across redraws. But what if I wanted to inject the view model? Just pass it through the initializer:

struct ProfileView: View {
    @State private var viewModel: any ProfileViewModelProtocol

    init(viewModel: any ProfileViewModelProtocol) {
        self._viewModel = State(wrappedValue: viewModel) // (1)
    }

    var body: some View {
        // ...
    }
}

// Usage
let vm = ProfileViewModel(apiClient: apiClient, userId: userId) // Implementation
ProfileView(viewModel: vm)

The underscore syntax (1) accesses the wrapper itself rather than the wrapped value. It’s the same pattern you’d use with _viewModel = StateObject(wrappedValue:).

Moving on, let’s discuss a situation when we need to bind a view model property to a particular UI component. With @Observable, you don’t need any property wrapper for read-only access – a plain let or var property works fine. But what if you need to pass a binding to a child view, like a TextField? That’s where @Bindable comes in:

// Before
@ObservedObject var viewModel: SettingsViewModel

var body: some View {
    TextField("Name", text: $viewModel.displayName)
}

// After
@Bindable var viewModel: SettingsViewModel

var body: some View {
    TextField("Name", text: $viewModel.displayName)
}

Important: @Bindable is not a direct replacement for @ObservedObject. It serves a narrower purpose – enabling the $ syntax for creating bindings. If you only need to read from the view model, use a plain property instead:

struct ProfileView: View {
    let viewModel: ProfileViewModel  // Plain property for read-only access
    
    var body: some View {
        Text(viewModel.username)  // Tracking works - no wrapper needed
    }
}

Use @Bindable only when you specifically need bindings. And remember: it doesn’t manage lifecycle. Make sure the view model is preserved somewhere upstream (via @State or a parent).

You might also consider migrating nested ObservableObjects. I haven’t encountered many cases like this – I generally avoided the pattern in the first place. It required manual objectWillChange chaining to make views update properly, which wasn’t ideal. With @Observable, however, just mark both levels and it should work:

@Observable
class UserSession {
    var settings: UserSettings = UserSettings()
}

@Observable
class UserSettings {
    var theme: String = "light"
}

The Gotchas - Things That Still Bite

Even after you’ve internalized the correct patterns, @Observable has a few surprises waiting for you.

In general, @Observable handles computed properties correctly. When a view accesses such a property, the getter executes and tries to read the underlying stored properties. As long as these are tracked, the computed property based on them will be tracked as well. Let’s look at an example:

@Observable
class PersonViewModel {
    var firstName: String = ""
    var lastName: String = ""

    var fullName: String {
        firstName + " " + lastName
    }
}

… a view that only reads fullName :

var body: some View {
    Text(viewModel.fullName)  // will be updated when firstName changes
}

This works because when fullName‘s getter runs, it accesses firstName and lastName. The Observation framework tracks those accesses, so when either property changes, the view updates. But what if a computed property is e.g. derived from non-Observable dependencies? How about this simple greeting:

@Observable
class SettingsViewModel {
    var username: String = "John"
    
    var greeting: String {
        let hour = Calendar.current.component(.hour, from: Date())
        let timeOfDay = hour < 12 ? "morning" : "evening"
        return "Good \(timeOfDay), \(username)"
    }
}
// Changes to `username` → view updates ✅
// Time passing (morning → evening) → view does NOT update ❌

To put it simply: the Observation framework isn’t notified when the hour changes, so the view won’t update. This makes perfect sense when you think about it. Did knowing this help me avoid unpleasant surprises? Sometimes…

The fix? Make the external dependency a stored property that you can update:

@Observable
class SettingsViewModel {
    var username: String = "John"
    var currentDate: Date = Date()  // Stored property - is tracked!
    
    var greeting: String {
        let hour = Calendar.current.component(.hour, from: currentDate)
        let timeOfDay = hour < 12 ? "morning" : "evening"
        return "Good \(timeOfDay), \(username)"
    }
    
    func refreshTime() {
        currentDate = Date()
    }
}

Now greeting reads from currentDate (tracked) instead of Date() (not tracked). Call refreshTime() whenever you need the greeting to update – on appear, via a timer, or when the app returns to foreground.

On the flip side, sometimes you don’t want a property to trigger view updates. Maybe it’s a reference to a service, or something that changes frequently but has no UI impact. That’s where @ObservationIgnored comes in:

@Observable
class SearchViewModel {
    var value: String = "" // Will trigger view update if observed
    // ...

    @ObservationIgnored
    private let analyticsTracker: AnalyticsTracker 

    @ObservationIgnored
    private var requestCount: Int = 0

    func search() {
        requestCount += 1  // No view update triggered
        analyticsTracker.track("search", query: query)
        // ...
    }
}

Properties marked with @ObservationIgnored are invisible to the Observation framework. Views won’t update when they change, and accessing them won’t register for observation tracking.

Common use cases:

  • Dependencies injected into the view model
  • Internal bookkeeping counters, flags, timestamps
  • Cache and memoization stores
  • High-frequency state that would cause performance issues if observed

Sometimes, you’ll need to work with collections of @Observable objects. These might not behave as you’d expect. Let’s look at an example:

@Observable
class User {
    var name: String
}

struct UserListView: View {
    @State var users: [User] = []
}

Modifying a property inside a User works as expected. When we change users[0].name, the observing view will update. But adding or removing users from the array? That’s a mutation of the array itself, not the User objects. If you’re storing the array in @State, it works. But if the array is a plain property? It won’t.

The cleanest solution is to keep collections inside an @Observable container:

@Observable
class UserListViewModel {
    var users: [User] = []

    func addUser(_ user: User) {
        users.append(user)  // This mutation is tracked
    }
}

Does Observable offer better performance?

With ObservableObject, any @Published property change fired objectWillChange, and every view observing that object re-evaluated its body. Even if your view only cared about one @Published property, it would redraw when any other such property changed. SwiftUI’s rendering engine is smart enough to detect when the regenerated view tree is identical to the one already displayed, but the view body computation still needs to happen.

With @Observable, SwiftUI tracks exactly which properties each view accesses. So, when a view relies only on one of the view model properties, it would not get redrawn unless that property is updated. No wasted body evaluations.

In theory, this is a big deal. In practice? You probably won’t even notice the difference.

From my experience, most performance issues in apps result from problems unrelated to over-observation. View body recalculation is usually fast and straightforward. If it’s not, your view is likely too complicated: complex layouts, branching UI, etc. Not to mention the perpetual view redraw caused by the view model lifecycle trap we discussed previously.

So, where can granular observation noticeably improve performance? Let’s take a look:

  • High-frequency updates:
    Imagine a cryptocurrency ticker updating 10 times per second. Now, imagine a list of such assets. With 50 assets and 10 updates per second, that’s the difference between up to 500 body evaluations per second and 10.

  • Complex dashboards:
    Take a banking app as an example. Recent transactions, credit score widget, investment portfolio chart, etc. Dozens of separate UI components, each requiring specific data. Some of them update every second, some even faster, some slower. With Observable, each component redraws precisely when the data it needs is updated.

  • Forms with many fields:
    A settings screen with 15 toggles, 5 text fields, and a few pickers? Sure, let’s do this! And then the user types in one field and the entire form redraws… @Observable makes each control independent.

Want to see this in action? You can easily check how often your views redraw with this simple visual debugger. It’s a ViewModifier that draws a random-colored border every time the view redraws:

extension View {
    func debugRedraw() -> some View {
        border(Color(
            red: .random(in: 0...1),
            green: .random(in: 0...1),
            blue: .random(in: 0...1)
        ), width: 2)
    }
}

// Usage
var body: some View {
    VStack {
        ProfileCard(user: viewModel.user)
            .debugRedraw()

        TransactionList(items: viewModel.transactions)
            .debugRedraw()

        // ...
    }
}

Simply interact with your app. Every time a view’s body is re-evaluated, its border flashes a new color. You tap something that should only affect ProfileCard, but the TransactionList border flashes as well? Congratulations! You’ve just found unnecessary redraws that @Observable could eliminate.

When Should You Migrate?

As we’ve discussed, migrating to Observable shouldn’t be a question of if, but when. It’s a much simpler, better-optimized, and more convenient framework than ObservableObject. That said, sometimes migration might not be the best option:

Migrate now if:

  • Your minimum deployment target is iOS 17+. No backward compatibility concerns, no reason to wait.
  • You’re starting a new feature, especially if your app is modular. Greenfield code is the perfect place to use @Observable without touching existing, working code.
  • You have solid test coverage. That was my saving grace during my last migration. We’d added snapshot and UI test coverage for most of the app’s critical views and flows, so we immediately knew where the migration failed.
  • You have to type-erase your SwiftUI views to avoid generics infection.

However, you might want to wait if:

  • iOS 16 support is required for business reasons. A word of advice: check your analytics. If iOS 16 users are down to single digits, it might be time to have the conversation about bumping that deployment target.
  • You have a large codebase with no tests. Without automated verification, you’ll be playing whack-a-mole with all the issues we’ve discussed. Maybe it’s worth investing some time in implementing a battery of snapshot tests first?
  • Your app is written primarily in UIKit. Jumping from @ObservedObject to @Observable won’t be a big leap. Jumping from UIKit to SwiftUI? Not so much.
  • If it ain’t broke, don’t fix it. If your current ObservableObject setup works fine and you’re not hitting the pain points @Observable solves, there’s no rush to refactor your entire UI layer.

As always, common sense should prevail. You don’t have to migrate everything at once. A pragmatic middle ground: use @Observable for new features and modules, keep existing code on ObservableObject until you have a reason to touch it. And as always, apply the good old Boy Scout Rule: when implementing changes to a screen, migrate it along the way.

Summary

@Observable replaces ObservableObject with a fundamentally different approach. Instead of broadcasting something changed to all subscribers, it tracks exactly which properties each view accesses and updates only when those properties change. The result? Better performance, cleaner syntax, and protocols that finally work with existentials.

However, the migration isn’t a simple find-and-replace. You need to be mindful of who owns a view model. If the view creates and owns it, use @State to preserve it across redraws. If the view receives it from a parent, use @Bindable when you need bindings or a plain property for read-only access.

Once you understand these nuances, migrating to Observable shouldn’t be intimidating. As I write this in Q1 2026, iOS 17 adoption is high enough that most apps can make the move. However, resist the temptation to refactor the entire app at once. Pick a feature – new or existing – get comfortable with the patterns, and keep applying the Boy Scout Rule. It’s definitely worth it!

Don’t miss any new blogposts!

    By subscribing, you agree with our privacy policy and our terms and conditions.

    Disclaimer: All memes used in the post were made for fun and educational purposes only 😎
    They were not meant to offend anyone 🙌
    All copyrights belong to their respective owners🧐
    The thumbnail was generated with text2image ❤️ 
    Some images were generated using Leonardo AI ❤️
    Audio version generated with TTS Maker ❤️