Chapter 5: Bridging NSFetchedResultsController to SwiftUI
- Massive Views: They forcefully bind your Core Data context directly to your UI views, leading to views that are overly aware of data persistence mechanics.
- Testing Nightmare: It is practically impossible to unit test fetching logic when it is embedded inside a SwiftUI property wrapper.
- Rigid Paradigms: Customizing data delivery, debouncing rapid changes, or mapping managed objects to view models becomes exceedingly difficult.
The Architecture: Why NSFetchedResultsController?
- Memory Efficiency and Faulting: FRC automatically batches fetches using
fetchBatchSize. When dealing with 100,000 records, it doesn't load them all into memory. It fetches an array of lightweightNSManagedObjectIDreferences and only "faults" (fully loads) the objects as they are demanded by the UI. - Granular Reactivity: Through
NSFetchedResultsControllerDelegate, it actively listens to theNSManagedObjectContextfor any insertions, deletions, or updates that match its fetch request. It is highly optimized to only notify you when changes affect its specific query. - Sectioning and Caching: It natively supports grouping data into sections based on a specific property, and can cache the section geometry to disk, making subsequent loads lightning fast.
Setting up the Expense Repository
import Foundation
import CoreData
/// Protocol defining the interface for our expense repository.
protocol ExpenseRepositoryProtocol {
var expensesStream: AsyncStream<[Expense]> { get }
func fetchExpenses()
func delete(expense: Expense)
}
final class ExpenseRepository: NSObject, ExpenseRepositoryProtocol {
private let context: NSManagedObjectContext
private let fetchedResultsController: NSFetchedResultsController<Expense>
// We use an AsyncStream continuation to yield the current state of our expenses.
// This ensures new consumers immediately receive the latest data.
private var expensesContinuation: AsyncStream<[Expense]>.Continuation?
public lazy var expensesStream: AsyncStream<[Expense]> = {
AsyncStream { continuation in
self.expensesContinuation = continuation
}
}()
init(context: NSManagedObjectContext) {
self.context = context
// 1. Create the Fetch Request
let fetchRequest: NSFetchRequest<Expense> = Expense.fetchRequest()
// 2. Add Sort Descriptors (Required for NSFetchedResultsController)
// Without at least one sort descriptor, the FRC will crash at runtime.
// The SQLite database uses this to order the fetched object IDs.
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Expense.date, ascending: false)]
// 3. Optimize Memory with Batch Size
// This tells SQLite to fetch rows in chunks of 20.
fetchRequest.fetchBatchSize = 20
// 4. Initialize the NSFetchedResultsController
self.fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: context,
sectionNameKeyPath: nil,
cacheName: "ExpenseListCache" // Optional: Caches the pre-computed fetch geometry
)
super.init()
// 5. Assign the delegate to listen for context changes
self.fetchedResultsController.delegate = self
}
func fetchExpenses() {
do {
try fetchedResultsController.performFetch()
// Yield the initial fetch results to our stream
updateSnapshot()
} catch {
print("Failed to fetch expenses: \(error.localizedDescription)")
// In a production app, handle this error properly
}
}
func delete(expense: Expense) {
context.delete(expense)
// While we save here for simplicity, in complex apps, saving is usually
// delegated to a centralized Unit of Work or Context Manager.
do {
try context.save()
} catch {
print("Failed to delete expense: \(error.localizedDescription)")
}
}
private func updateSnapshot() {
// Extract the fetched objects safely
let fetchedExpenses = fetchedResultsController.fetchedObjects ?? []
// Push the new array downstream
expensesContinuation?.yield(fetchedExpenses)
}
}
Deep Dive: Repository Construction & Performance
AsyncStream: We bridge the imperative delegate pattern of FRC to a modern Swift Concurrency stream. By yielding the array ofExpenseobjects to the stream, we aren't duplicating memory. We are simply holding references toNSManagedObjectinstances, many of which remain unfulfilled "faults" until the UI asks for their properties.fetchBatchSizevsfetchLimit: A common misconception.fetchLimitcaps the total number of items returned (e.g., "Top 10").fetchBatchSizedictates memory management. If a user has 10,000 expenses, a batch size of 20 means Core Data retrieves 10,000 row IDs from SQLite, but only populates data for 20 objects at a time as you scroll.cacheName: When you provide acacheName, the FRC saves the pre-computed section information and object ordering to a file. On subsequent launches, it reads this cache instead of querying SQLite, offering a massive speedup.[!WARNING] If you change the
fetchRequest(e.g., applying a different predicate or sort descriptor), you must callNSFetchedResultsController.deleteCache(withName:)before initializing the new FRC, otherwise your app will crash or display stale data.
Mastering NSFetchedResultsControllerDelegate in SwiftUI
import Foundation
import CoreData
protocol ExpenseRepositoryProtocol {
var expensesStream: AsyncStream<[Expense]> { get }
func fetchExpenses()
func delete(expense: Expense)
}
final class ExpenseRepository: NSObject, ExpenseRepositoryProtocol, NSFetchedResultsControllerDelegate {
private let context: NSManagedObjectContext
let fetchedResultsController: NSFetchedResultsController<Expense>
// We use an AsyncStream continuation to yield the current state of our expenses.
// This ensures new consumers immediately receive the latest data.
private var expensesContinuation: AsyncStream<[Expense]>.Continuation?
public lazy var expensesStream: AsyncStream<[Expense]> = {
AsyncStream { continuation in
self.expensesContinuation = continuation
}
}()
init(context: NSManagedObjectContext) {
self.context = context
// 1. Create the Fetch Request
let fetchRequest: NSFetchRequest<Expense> = Expense.fetchRequest()
// 2. Add Sort Descriptors (Required for NSFetchedResultsController)
// Without at least one sort descriptor, the FRC will crash at runtime.
// The SQLite database uses this to order the fetched object IDs.
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Expense.date, ascending: false)]
// 3. Optimize Memory with Batch Size
// This tells SQLite to fetch rows in chunks of 20.
fetchRequest.fetchBatchSize = 20
// 4. Initialize the NSFetchedResultsController
self.fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: context,
sectionNameKeyPath: nil,
cacheName: "ExpenseListCache" // Optional: Caches the pre-computed fetch geometry
)
super.init()
// 5. Assign the delegate to listen for context changes
self.fetchedResultsController.delegate = self
}
func fetchExpenses() {
do {
try fetchedResultsController.performFetch()
// Yield the initial fetch results to our stream
updateSnapshot()
} catch {
print("Failed to fetch expenses: \(error.localizedDescription)")
// In a production app, handle this error properly
}
}
func delete(expense: Expense) {
context.delete(expense)
// While we save here for simplicity, in complex apps, saving is usually
// delegated to a centralized Unit of Work or Context Manager.
do {
try context.save()
} catch {
print("Failed to delete expense: \(error.localizedDescription)")
}
}
func updateSnapshot() {
// Extract the fetched objects safely
let fetchedExpenses = fetchedResultsController.fetchedObjects ?? []
// Push the new array downstream
expensesContinuation?.yield(fetchedExpenses)
}
// Called after the FRC has processed all changes in the current event loop.
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
updateSnapshot()
}
}
Threading Considerations & Contexts
[!IMPORTANT] The
NSFetchedResultsControllermust be initialized on the same thread as its associatedNSManagedObjectContext. Furthermore, its delegate methods will be invoked on that same thread.
Constructing the ViewModel
import Foundation
import Observation
import SwiftUI
@MainActor
@Observable
final class ExpenseListViewModel {
var expenses: [Expense] = []
private let repository: ExpenseRepositoryProtocol
init(repository: ExpenseRepositoryProtocol) {
self.repository = repository
setupSubscriptions()
// Trigger the initial fetch on load
repository.fetchExpenses()
}
private func setupSubscriptions() {
Task { @MainActor in
for await newExpenses in repository.expensesStream {
self.expenses = newExpenses
}
}
}
func deleteExpense(at offsets: IndexSet) {
for index in offsets {
let expense = expenses[index]
repository.delete(expense: expense)
}
}
// Formatting logic kept out of the View
func formatAmount(_ amount: Double) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = "USD"
return formatter.string(from: NSNumber(value: amount)) ?? "$0.00"
}
}
Building the SwiftUI View
[!TIP] Sometimes, when inserting new objects, they have a temporary
NSManagedObjectIDuntil the context is saved. This can occasionally confuse SwiftUI'sListdiffing algorithm if you rely implicitly onIdentifiable. It is often safer to explicitly define theidparameter as\.objectIDin yourForEach.
import SwiftUI
struct ExpenseListView: View {
@State private var viewModel: ExpenseListViewModel
init(viewModel: ExpenseListViewModel) {
_viewModel = State(wrappedValue: viewModel)
}
var body: some View {
NavigationView {
List {
// Explicitly using \.objectID for stable diffing
ForEach(viewModel.expenses, id: \.objectID) { expense in
ExpenseRowView(expense: expense)
}
.onDelete(perform: viewModel.deleteExpense)
}
.navigationTitle("Expenses")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { /* Present add sheet */ }) {
Image(systemName: "plus")
}
}
}
.overlay(
Group {
if viewModel.expenses.isEmpty {
Text("No expenses yet. Tap + to add one.")
.foregroundColor(.secondary)
}
}
)
}
}
}
The "Related Object" Gotcha in NSFetchedResultsController
struct ExpenseRowView: View {
// @ObservedObject forces the row to re-render if ANY property
// or relationship on this specific NSManagedObject changes.
@ObservedObject var expense: Expense
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(expense.title ?? "Unknown")
.font(.headline)
// If category name changes elsewhere, @ObservedObject catches it
if let categoryName = expense.category?.name {
Text(categoryName)
.font(.caption)
.padding(4)
.background(Color.blue.opacity(0.1))
.cornerRadius(4)
}
}
Spacer()
Text(String(format: "$%.2f", expense.amount))
}
.padding(.vertical, 4)
}
}
Advanced FRC: Sectioned Data
[!CAUTION] Never use a transient (computed) property as the
sectionNameKeyPath. The FRC uses SQLite to group the objects. If you use a transient property, Core Data must load every single object into memory to calculate the grouping, entirely negating the benefits of batching.
import Foundation
import CoreData
func configureSectionedFRC(context: NSManagedObjectContext) -> NSFetchedResultsController<Expense> {
let fetchRequest: NSFetchRequest<Expense> = Expense.fetchRequest()
// 1st Sort Descriptor MUST match the section grouping key path
let categorySort = NSSortDescriptor(keyPath: \Expense.category?.name, ascending: true)
// 2nd Sort Descriptor orders items within the section
let dateSort = NSSortDescriptor(keyPath: \Expense.date, ascending: false)
fetchRequest.sortDescriptors = [categorySort, dateSort]
return NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: context,
sectionNameKeyPath: "category.name", // Group by category name
cacheName: nil
)
}
Bridging Sections to AsyncStream
struct ExpenseSection: Identifiable {
let id = UUID()
let name: String
let expenses: [Expense]
}
import Foundation
import CoreData
final class SectionedExpenseRepository: NSObject, NSFetchedResultsControllerDelegate {
private let context: NSManagedObjectContext
private let fetchedResultsController: NSFetchedResultsController<Expense>
private var sectionsContinuation: AsyncStream<[ExpenseSection]>.Continuation?
public lazy var sectionsStream: AsyncStream<[ExpenseSection]> = {
AsyncStream { continuation in
self.sectionsContinuation = continuation
}
}()
init(context: NSManagedObjectContext) {
self.context = context
let fetchRequest: NSFetchRequest<Expense> = Expense.fetchRequest()
fetchRequest.sortDescriptors = [
NSSortDescriptor(keyPath: \Expense.category?.name, ascending: true),
NSSortDescriptor(keyPath: \Expense.date, ascending: false)
]
self.fetchedResultsController = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: context,
sectionNameKeyPath: "category.name",
cacheName: nil
)
super.init()
self.fetchedResultsController.delegate = self
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
updateSnapshot()
}
private func updateSnapshot() {
guard let sections = fetchedResultsController.sections else { return }
let mappedSections = sections.map { sectionInfo -> ExpenseSection in
// sectionInfo.objects contains the faults for this specific section
let expenses = (sectionInfo.objects as? [Expense]) ?? []
return ExpenseSection(name: sectionInfo.name, expenses: expenses)
}
sectionsContinuation?.yield(mappedSections)
}
}
Advanced Techniques: Dynamic Filtering and Manual Fetching
import Foundation
import CoreData
extension ExpenseRepository {
func updateSearchQuery(_ query: String) {
// 1. Purge the cache if you are modifying the fetch request!
NSFetchedResultsController<Expense>.deleteCache(withName: "ExpenseListCache")
let fetchRequest = fetchedResultsController.fetchRequest
if query.isEmpty {
fetchRequest.predicate = nil
} else {
// [cd] implies case-insensitive and diacritic-insensitive
fetchRequest.predicate = NSPredicate(format: "title CONTAINS[cd] %@", query)
}
do {
// 2. Re-perform the fetch
try fetchedResultsController.performFetch()
// 3. Manually trigger an update (performFetch doesn't call the delegate)
updateSnapshot()
} catch {
print("Search fetch failed: \(error)")
}
}
}
import Foundation
import Observation
@Observable
class ExpenseListViewModel {
private let repository: ExpenseRepository
private var searchTask: Task<Void, Never>?
init(repository: ExpenseRepository) {
self.repository = repository
}
var searchText = "" {
didSet {
// Cancel previous task and create a new one to debounce
searchTask?.cancel()
searchTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 300_000_000)
if !Task.isCancelled {
repository.updateSearchQuery(searchText)
}
}
}
}
}
Faulting, Batching, and the "UI Freeze" Constraint
import Foundation
func calculateBadTotalSum(expenses: [Expense]) -> Double {
// A VERY BAD IDEA if fetchBatchSize is enabled and the list is large
return expenses.reduce(0) { $0 + $1.amount }
}
import Foundation
import CoreData
extension ExpenseRepository {
func calculateTotalAmount() -> Double {
let fetchRequest = NSFetchRequest(entityName: "Expense")
fetchRequest.resultType = .dictionaryResultType
let sumExpressionDesc = NSExpressionDescription()
sumExpressionDesc.name = "sumAmount"
sumExpressionDesc.expression = NSExpression(forFunction: "sum:", arguments: [NSExpression(forKeyPath: "amount")])
sumExpressionDesc.expressionResultType = .doubleAttributeType
fetchRequest.propertiesToFetch = [sumExpressionDesc]
do {
let results = try context.fetch(fetchRequest)
if let resultDict = results.first, let sum = resultDict["sumAmount"] as? Double {
return sum
}
} catch {
print("Failed to calculate sum: \(error)")
}
return 0.0
}
}