The Gotcha of Mixing SwiftUI's @Observable with Core Data
Learn why your SwiftUI views might stop updating when mixing the modern @Observable macro with Core Data NSManagedObjects, and how to fix it.
The Gotcha of Mixing SwiftUI's @Observable with Core Data
If you are building modern iOS apps, you are likely adopting Apple's new @Observable macro to power your state management. It's incredibly fast, requires less boilerplate than the old ObservableObject, and feels like magic.
However, if you are mixing @Observable view models with a legacy Core Data stack, you might eventually run into a frustrating bug: Your UI stops updating when you change your data.
Let's look at why this happens and how to fix it.
Imagine we have an @Observable manager class that uses an NSFetchedResultsController to fetch a list of Movie records from Core Data.
import SwiftUI
import CoreData
@Observable
class MovieManager: NSObject {
var movies: [Movie] = []
// It has NSManagedObjectContext which helps fetch, update and save movies.
}
In our view, we iterate over these movies and pass them into a child view to display and update their rating:
struct ContentView: View {
@Environment(MovieManager.self) private var manager
var body: some View {
List(manager.movies, id: \.id) { movie in
RatingView(movie: movie)
}
}
}
This is our RatingView where the user taps to change the rating:
struct RatingView: View {
@Environment(MovieManager.self) private var manager
var movie: Movie
var body: some View {
HStack {
Text(movie.name)
Button("Increment Rating") {
movie.rating += 1
// saving context
manager.save()
}
}
}
}
The Problem
When you tap the "Increment Rating" button, the Core Data context saves, the database updates, and your MovieManager successfully fetches the fresh data.
But your RatingView does not visually update.
The "Why"
This bug is a perfect storm of three separate framework behaviors colliding:
NSManagedObjectis a Reference Type: Themovievariable inRatingViewis just a pointer to a class in memory. When you updatemovie.rating, the object itself hasn't changed its address.- SwiftUI's Diffing Engine: When your
MovieManagerfetches the updated list of movies, it handsContentViewan array of the exact same object references with the exact sameids. SwiftUI looks at theList, sees that the IDs haven't changed, and decides it doesn't need to redraw the rows. - Observation Mismatch: The new
@Observablemacro uses property tracking. ButNSManagedObjectdoes not use the@Observablemacro—it relies on the older CombineObservableObjectprotocol. Because of this, SwiftUI's modern observation engine has no idea that the properties inside themovieobject have changed.
The Solution
The fix is incredibly simple but requires you to remember that Core Data objects still live in the Combine world.
Since NSManagedObject conforms to ObservableObject out of the box, we just need to tell SwiftUI to observe it using the older @ObservedObject property wrapper inside our child view:
struct RatingView: View {
// 👇 The Magic Fix
@ObservedObject var movie: Movie
var body: some View {
// ...
}
}
By marking the Movie as an @ObservedObject, we explicitly tell SwiftUI to listen to the publishers that Core Data automatically synthesizes for its managed properties.
Now, the moment movie.rating changes, RatingView receives the event, invalidates its body, and re-renders perfectly!
Summary
When mixing the new Observation framework with Core Data, remember that:
- Your managers/controllers can happily use
@Observable. - But your
NSManagedObjectinstances must be observed using@ObservedObjectwhen passed into child views.